Skip to main content

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 quote::{format_ident, quote};
11
12/// Common header inserted at the top of generated Rust files.
13#[must_use]
14pub fn preamble() -> String {
15    indoc::formatdoc!(
16        r"
17            //
18            // AUTOGENERATED. DO NOT EDIT.
19            // generated by globetrotter v{version}.
20            //
21        ",
22        version = std::env!("CARGO_PKG_VERSION"),
23    )
24}
25
26fn argument_to_rust_field_name(name: &str) -> String {
27    let field_name = name.replace(' ', "").replace(['-', '.'], "_");
28    field_name.to_case(Case::Snake)
29}
30
31/// The Rust enum variant identifier generated for a translation key, e.g.
32/// `translation.greeting` becomes `TranslationGreeting`.
33#[must_use]
34pub fn key_to_rust_enum_variant(key: &str) -> String {
35    let variant_name = key.replace(' ', "").replace(['-', '.'], "_");
36    variant_name.to_case(Case::UpperCamel)
37}
38
39/// The comparison traits a generated field type implements.
40///
41/// The generated enum can only derive a comparison trait when every field
42/// implements it, so these are combined across fields with [`Self::and`].
43#[derive(Clone, Copy, Debug)]
44struct Comparisons {
45    eq: bool,
46    partial_ord: bool,
47    ord: bool,
48}
49
50impl Comparisons {
51    /// `Eq` and `Ord`, as implemented by integers, booleans, and strings.
52    const TOTAL: Self = Self {
53        eq: true,
54        partial_ord: true,
55        ord: true,
56    };
57    /// `PartialOrd` only, as implemented by floats.
58    const PARTIAL: Self = Self {
59        eq: false,
60        partial_ord: true,
61        ord: false,
62    };
63    /// `Eq` without any ordering, as implemented by `serde_json::Value`.
64    const EQ_ONLY: Self = Self {
65        eq: true,
66        partial_ord: false,
67        ord: false,
68    };
69
70    /// Narrows to the traits both `self` and `other` implement.
71    fn and(self, other: Self) -> Self {
72        Self {
73            eq: self.eq && other.eq,
74            partial_ord: self.partial_ord && other.partial_ord,
75            ord: self.ord && other.ord,
76        }
77    }
78}
79
80/// The Rust type generated for one template argument.
81struct RustType {
82    tokens: proc_macro2::TokenStream,
83    /// Whether the type borrows from the deserialized input and therefore
84    /// needs the enum's lifetime parameter.
85    borrows: bool,
86    comparisons: Comparisons,
87}
88
89impl RustType {
90    /// An owned type with a total order.
91    fn owned(tokens: proc_macro2::TokenStream) -> Self {
92        Self {
93            tokens,
94            borrows: false,
95            comparisons: Comparisons::TOTAL,
96        }
97    }
98}
99
100trait IntoRustType {
101    fn into_rust_type(self) -> RustType;
102}
103
104impl IntoRustType for model::ArgumentType {
105    fn into_rust_type(self) -> RustType {
106        match self {
107            Self::Number | Self::Integer => RustType::owned(quote! {i64}),
108            Self::Float => RustType {
109                tokens: quote! {f64},
110                borrows: false,
111                comparisons: Comparisons::PARTIAL,
112            },
113            Self::Boolean => RustType::owned(quote! {bool}),
114            // Keep ISO 8601 values as strings so generated bindings do not
115            // impose a date-time crate.
116            Self::String | Self::Iso8601DateTimeString => RustType {
117                tokens: quote! {&'a str},
118                borrows: true,
119                comparisons: Comparisons::TOTAL,
120            },
121            Self::Any => RustType {
122                tokens: quote! {serde_json::Value},
123                borrows: false,
124                comparisons: Comparisons::EQ_ONLY,
125            },
126        }
127    }
128}
129
130/// A collision between translation keys that map to one Rust enum identifier.
131#[derive(thiserror::Error, Debug)]
132pub struct DuplicateIdentifierError {
133    identifier: String,
134    keys: Vec<String>,
135}
136
137impl std::fmt::Display for DuplicateIdentifierError {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        write!(
140            f,
141            "duplicate identifier `{}` (used by {})",
142            self.identifier,
143            self.keys.join(", ")
144        )
145    }
146}
147
148/// A collision between translation arguments that map to one Rust field name.
149#[derive(thiserror::Error, Debug)]
150pub struct DuplicateFieldError {
151    field: String,
152    enum_variant: String,
153    arguments: Vec<String>,
154    key: String,
155}
156
157impl std::fmt::Display for DuplicateFieldError {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        write!(
160            f,
161            "{}: duplicate field `{}` used by arguments {} of variant `{}`",
162            self.key,
163            self.field,
164            self.arguments
165                .iter()
166                .map(|arg| format!("{arg:?}"))
167                .collect::<Vec<_>>()
168                .join(", "),
169            self.enum_variant,
170        )
171    }
172}
173
174/// Errors that can occur while generating Rust translation bindings.
175#[derive(thiserror::Error, Debug)]
176pub enum Error {
177    /// Duplicate Rust enum identifier derived from translation keys.
178    #[error(transparent)]
179    DuplicateIdentifier(#[from] DuplicateIdentifierError),
180    /// Duplicate Rust struct field derived from translation arguments.
181    #[error(transparent)]
182    DuplicateField(#[from] DuplicateFieldError),
183    /// Error originating from the `syn` crate when pretty-printing generated code.
184    #[error("{0}")]
185    Syn(String),
186}
187
188/// One generated enum variant together with the properties of its fields.
189struct Variant {
190    tokens: proc_macro2::TokenStream,
191    /// Whether any field borrows from the deserialized input.
192    borrows: bool,
193    /// The comparison traits every field implements.
194    comparisons: Comparisons,
195}
196
197/// Generates the enum variant for one translation, rejecting argument names
198/// that normalize to the same field.
199fn generate_variant(
200    safe_key: &str,
201    key: &str,
202    translation: &model::Translation,
203) -> Result<Variant, Error> {
204    use itertools::Itertools;
205
206    let fields: Vec<_> = translation
207        .arguments
208        .iter()
209        .map(|(name, typ)| (argument_to_rust_field_name(name), name, typ))
210        .collect();
211
212    let duplicates: Vec<_> = fields
213        .iter()
214        .duplicates_by(|(safe_name, _, _)| safe_name)
215        .collect();
216
217    if let Some(first) = duplicates.first() {
218        let field = first.0.clone();
219        let arguments = duplicates
220            .into_iter()
221            .map(|(_, key, _)| (*key).clone())
222            .collect();
223        return Err(Error::from(DuplicateFieldError {
224            field,
225            arguments,
226            enum_variant: safe_key.to_string(),
227            key: key.to_string(),
228        }));
229    }
230
231    let mut borrows = false;
232    let mut comparisons = Comparisons::TOTAL;
233    let fields: Vec<_> = fields
234        .into_iter()
235        .map(|(safe_name, name, typ)| {
236            let field_ident = format_ident!("{safe_name}");
237            let typ = typ.into_rust_type();
238            borrows |= typ.borrows;
239            comparisons = comparisons.and(typ.comparisons);
240            let typ = typ.tokens;
241            quote! {
242                #[serde(rename = #name)]
243                #field_ident: #typ,
244            }
245        })
246        .collect();
247
248    let variant_name_ident = format_ident!("{safe_key}");
249    let tokens = quote! {
250        #variant_name_ident {
251            #(#fields)*
252        },
253    };
254    Ok(Variant {
255        tokens,
256        borrows,
257        comparisons,
258    })
259}
260
261/// Generates a Rust `Translation` enum for the given translations.
262///
263/// The generated code includes a `key` method that maps each variant back to
264/// its original translation key.
265///
266/// # Errors
267///
268/// Returns an error if translation keys or argument names would result in
269/// duplicate Rust identifiers, or if the generated code cannot be parsed by
270/// `syn` for pretty-printing.
271pub fn generate_translation_enum(translations: &model::Translations) -> Result<String, Error> {
272    use itertools::Itertools;
273
274    // Normalize every translation key to its generated variant name.
275    let enum_variant_names: Vec<_> = translations
276        .0
277        .iter()
278        .map(|(key, translation)| (key_to_rust_enum_variant(key.as_ref()), key, translation))
279        .collect();
280
281    // Reject collisions before generating an ambiguous enum.
282    let duplicates: Vec<_> = enum_variant_names
283        .iter()
284        .duplicates_by(|(safe_key, _, _)| safe_key)
285        .collect();
286
287    if let Some(first) = duplicates.first() {
288        let identifier = first.0.clone();
289        let keys = duplicates
290            .into_iter()
291            .map(|(_, key, _)| key.to_string())
292            .collect();
293        return Err(DuplicateIdentifierError { identifier, keys }.into());
294    }
295
296    // Generate each variant, tracking what the enum as a whole can derive.
297    let mut uses_lifetime = false;
298    let mut comparisons = Comparisons::TOTAL;
299    let mut enum_variants = Vec::with_capacity(enum_variant_names.len());
300    for (safe_key, key, translation) in &enum_variant_names {
301        let variant = generate_variant(safe_key, key.as_ref(), translation)?;
302        uses_lifetime |= variant.borrows;
303        comparisons = comparisons.and(variant.comparisons);
304        enum_variants.push(variant.tokens);
305    }
306
307    // Build the reverse mapping from generated variants to translation keys.
308    let enum_variant_keys: Vec<_> = enum_variant_names
309        .iter()
310        .map(|(safe_key, key, _)| {
311            let variant_name_ident = format_ident!("{safe_key}");
312            let key = key.as_ref();
313            quote! {
314                Self::#variant_name_ident { .. } => #key,
315            }
316        })
317        .collect();
318
319    // Introduce a lifetime only when at least one generated field borrows text.
320    let generics: syn::Generics = if uses_lifetime {
321        syn::parse_quote!(<'a>)
322    } else {
323        syn::Generics::default()
324    };
325    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
326
327    // Derive only the comparisons every field supports: floats have no total
328    // order and `serde_json::Value` has no order at all.
329    let eq = comparisons.eq.then(|| quote! { Eq, });
330    let partial_ord = comparisons.partial_ord.then(|| quote! { PartialOrd, });
331    let ord = comparisons.ord.then(|| quote! { Ord, });
332
333    let out = quote! {
334        #[derive(
335            Debug, Clone, PartialEq, #eq #partial_ord #ord ::serde::Serialize, ::serde::Deserialize,
336        )]
337        #[serde(untagged)]
338        pub enum Translation #generics {
339            #(#enum_variants)*
340        }
341
342        impl #impl_generics Translation #ty_generics #where_clause {
343            pub fn key(&self) -> &'static str {
344                match self {
345                    #(#enum_variant_keys)*
346                }
347            }
348        }
349    };
350
351    // Render and format the complete generated source file.
352    let code = pretty_print(&out).map_err(|err| Error::Syn(err.to_string()))?;
353    let code = format!("{}\n{}", preamble(), code);
354    Ok(code)
355}
356
357fn pretty_print<T>(input: T) -> Result<String, syn::Error>
358where
359    T: quote::ToTokens,
360{
361    let file: syn::File = syn::parse2(quote! { #input })?;
362    Ok(prettyplease::unparse(&file))
363}
364
365#[cfg(test)]
366mod tests {
367    use color_eyre::eyre;
368    use globetrotter_model::{self as model, diagnostics::Spanned};
369    use similar_asserts::assert_eq as sim_assert_eq;
370
371    /// String arguments introduce a lifetime on the generated enum, and an
372    /// `any` argument drops the ordering derives `serde_json::Value` lacks.
373    #[test_util::test]
374    fn generate_enum_with_lifetime() -> eyre::Result<()> {
375        let translations = [
376            (
377                Spanned::dummy("test.one".to_string()),
378                model::Translation {
379                    language: [(
380                        model::Language::En,
381                        Spanned::dummy("test.one in en".to_string()),
382                    )]
383                    .into_iter()
384                    .collect(),
385                    arguments: [].into_iter().collect(),
386                    file_id: 0,
387                    allow: std::collections::BTreeSet::new(),
388                },
389            ),
390            (
391                Spanned::dummy("test.two".to_string()),
392                model::Translation {
393                    language: [(
394                        model::Language::En,
395                        Spanned::dummy("test.two in en".to_string()),
396                    )]
397                    .into_iter()
398                    .collect(),
399                    arguments: [
400                        ("arg-one".to_string(), model::ArgumentType::String),
401                        ("ArgTwo".to_string(), model::ArgumentType::Number),
402                        ("Arg_Three".to_string(), model::ArgumentType::Any),
403                        ("ArgFour".to_string(), model::ArgumentType::Boolean),
404                        ("ArgFive".to_string(), model::ArgumentType::Integer),
405                    ]
406                    .into_iter()
407                    .collect(),
408                    file_id: 0,
409                    allow: std::collections::BTreeSet::new(),
410                },
411            ),
412        ];
413        let translations = model::Translations(translations.into_iter().collect());
414        let have = super::generate_translation_enum(&translations)?;
415        println!("{have}");
416
417        let want = indoc::indoc! {r#"
418            #[derive(Debug, Clone, PartialEq, Eq, ::serde::Serialize, ::serde::Deserialize)]
419            #[serde(untagged)]
420            pub enum Translation<'a> {
421                TestOne {},
422                TestTwo {
423                    #[serde(rename = "arg-one")]
424                    arg_one: &'a str,
425                    #[serde(rename = "ArgTwo")]
426                    arg_two: i64,
427                    #[serde(rename = "Arg_Three")]
428                    arg_three: serde_json::Value,
429                    #[serde(rename = "ArgFour")]
430                    arg_four: bool,
431                    #[serde(rename = "ArgFive")]
432                    arg_five: i64,
433                },
434            }
435            impl<'a> Translation<'a> {
436                pub fn key(&self) -> &'static str {
437                    match self {
438                        Self::TestOne { .. } => "test.one",
439                        Self::TestTwo { .. } => "test.two",
440                    }
441                }
442            }
443        "# };
444        let want = format!("{}\n{}", super::preamble(), want);
445        sim_assert_eq!(have: have, want: want);
446        Ok(())
447    }
448
449    /// Float arguments keep `PartialOrd` but cannot derive `Eq` or `Ord`.
450    #[test_util::test]
451    fn generate_enum_with_float() -> eyre::Result<()> {
452        let translations = [(
453            Spanned::dummy("cart.total".to_string()),
454            model::Translation {
455                language: [(
456                    model::Language::En,
457                    Spanned::dummy("{{count}} items for {{price}}".to_string()),
458                )]
459                .into_iter()
460                .collect(),
461                arguments: [
462                    ("count".to_string(), model::ArgumentType::Integer),
463                    ("price".to_string(), model::ArgumentType::Float),
464                ]
465                .into_iter()
466                .collect(),
467                file_id: 0,
468                allow: std::collections::BTreeSet::new(),
469            },
470        )];
471        let translations = model::Translations(translations.into_iter().collect());
472        let have = super::generate_translation_enum(&translations)?;
473        println!("{have}");
474
475        let want = indoc::indoc! {r#"
476            #[derive(Debug, Clone, PartialEq, PartialOrd, ::serde::Serialize, ::serde::Deserialize)]
477            #[serde(untagged)]
478            pub enum Translation {
479                CartTotal {
480                    #[serde(rename = "count")]
481                    count: i64,
482                    #[serde(rename = "price")]
483                    price: f64,
484                },
485            }
486            impl Translation {
487                pub fn key(&self) -> &'static str {
488                    match self {
489                        Self::CartTotal { .. } => "cart.total",
490                    }
491                }
492            }
493        "# };
494        let want = format!("{}\n{}", super::preamble(), want);
495        sim_assert_eq!(have: have, want: want);
496        Ok(())
497    }
498
499    /// Owned argument types produce an enum without unused generics.
500    #[test_util::test]
501    fn generate_enum_without_lifetime() -> eyre::Result<()> {
502        let translations = [
503            (
504                Spanned::dummy("test.one".to_string()),
505                model::Translation {
506                    language: [(
507                        model::Language::En,
508                        Spanned::dummy("test.one in en".to_string()),
509                    )]
510                    .into_iter()
511                    .collect(),
512                    arguments: [].into_iter().collect(),
513                    file_id: 0,
514                    allow: std::collections::BTreeSet::new(),
515                },
516            ),
517            (
518                Spanned::dummy("test.two".to_string()),
519                model::Translation {
520                    language: [(
521                        model::Language::En,
522                        Spanned::dummy("test.two in en".to_string()),
523                    )]
524                    .into_iter()
525                    .collect(),
526                    arguments: [("ArgTwo".to_string(), model::ArgumentType::Number)]
527                        .into_iter()
528                        .collect(),
529                    file_id: 0,
530                    allow: std::collections::BTreeSet::new(),
531                },
532            ),
533        ];
534        let translations = model::Translations(translations.into_iter().collect());
535        let have = super::generate_translation_enum(&translations)?;
536        println!("{have}");
537
538        let want = indoc::indoc! {r#"
539            #[derive(
540                Debug,
541                Clone,
542                PartialEq,
543                Eq,
544                PartialOrd,
545                Ord,
546                ::serde::Serialize,
547                ::serde::Deserialize,
548            )]
549            #[serde(untagged)]
550            pub enum Translation {
551                TestOne {},
552                TestTwo { #[serde(rename = "ArgTwo")] arg_two: i64 },
553            }
554            impl Translation {
555                pub fn key(&self) -> &'static str {
556                    match self {
557                        Self::TestOne { .. } => "test.one",
558                        Self::TestTwo { .. } => "test.two",
559                    }
560                }
561            }
562        "# };
563        let want = format!("{}\n{}", super::preamble(), want);
564        sim_assert_eq!(have: have, want: want);
565        Ok(())
566    }
567}