es_fluent_lang_macro/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use heck::ToUpperCamelCase as _;
4use proc_macro::TokenStream;
5use proc_macro_error2::{abort, abort_call_site, proc_macro_error};
6use proc_macro2::Span;
7use quote::quote;
8use syn::{
9    Fields, ItemEnum, LitStr, Variant, parse_macro_input, parse_quote, spanned::Spanned as _,
10};
11
12mod supported_locales;
13
14fn supported_language_keys_for(
15    lang: &unic_langid::LanguageIdentifier,
16) -> impl Iterator<Item = String> {
17    use std::collections::HashSet;
18
19    let mut seen = HashSet::new();
20    let mut keys = Vec::new();
21
22    let mut push_key = |key: String| {
23        if seen.insert(key.clone()) {
24            keys.push(key);
25        }
26    };
27
28    // Full canonical form (language-script-region-variants)
29    push_key(lang.to_string());
30
31    // Canonical form without variants (used for fallback checks)
32    let mut without_variants = lang.clone();
33    without_variants.clear_variants();
34    push_key(without_variants.to_string());
35
36    // Drop region (e.g., `en-Latn-US` -> `en-Latn`)
37    if without_variants.region.is_some() {
38        let mut no_region = without_variants.clone();
39        no_region.region = None;
40        push_key(no_region.to_string());
41    }
42
43    // Drop script (e.g., `sr-Cyrl-RS` -> `sr-RS`)
44    if without_variants.script.is_some() {
45        let mut no_script = without_variants.clone();
46        no_script.script = None;
47        push_key(no_script.to_string());
48    }
49
50    // Just the base language subtag (e.g., `en`)
51    push_key(without_variants.language.to_string());
52
53    keys.into_iter()
54}
55
56fn is_supported_language(
57    lang: &unic_langid::LanguageIdentifier,
58    supported: &std::collections::HashSet<&'static str>,
59) -> bool {
60    supported_language_keys_for(lang).any(|key| supported.contains(key.as_str()))
61}
62
63/// Attribute macro that expands a language enum based on the `i18n.toml` configuration.
64/// Which generates variants for each language in the i18n folder structure.
65#[proc_macro_error]
66#[proc_macro_attribute]
67pub fn es_fluent_language(attr: TokenStream, item: TokenStream) -> TokenStream {
68    if !attr.is_empty() {
69        abort_call_site!("#[es_fluent_language] does not accept any arguments");
70    }
71
72    let mut input_enum = parse_macro_input!(item as ItemEnum);
73    let enum_ident = input_enum.ident.clone();
74    let enum_span = enum_ident.span();
75
76    if !input_enum.generics.params.is_empty() {
77        abort!(
78            input_enum.generics.span(),
79            "#[es_fluent_language] does not support generic enums"
80        );
81    }
82
83    if !input_enum.variants.is_empty() {
84        abort!(
85            enum_span,
86            "#[es_fluent_language] expects an enum without variants"
87        );
88    }
89
90    let config = es_fluent_toml::I18nConfig::read_from_manifest_dir()
91        .unwrap_or_else(|err| abort!(enum_span, "failed to read i18n configuration: {}", err));
92
93    let mut languages = config
94        .available_languages()
95        .unwrap_or_else(|err| abort!(enum_span, "failed to collect available languages: {}", err));
96
97    let fallback_language = config
98        .fallback_language_identifier()
99        .unwrap_or_else(|err| abort!(enum_span, "failed to parse fallback language: {}", err));
100
101    if !languages.iter().any(|lang| lang == &fallback_language) {
102        languages.push(fallback_language.clone());
103    }
104
105    if languages.is_empty() {
106        abort!(
107            enum_span,
108            "no languages found under the configured assets directory"
109        );
110    }
111
112    let fallback_canonical = fallback_language.to_string();
113
114    let mut language_entries: Vec<(String, unic_langid::LanguageIdentifier)> = languages
115        .into_iter()
116        .map(|lang| {
117            let canonical = lang.to_string();
118            (canonical, lang)
119        })
120        .collect();
121
122    language_entries.sort_by(|a, b| a.0.cmp(&b.0));
123    language_entries.dedup_by(|a, b| a.0 == b.0);
124
125    let supported_keys: std::collections::HashSet<&'static str> =
126        supported_locales::SUPPORTED_LANGUAGE_KEYS
127            .iter()
128            .copied()
129            .collect();
130
131    let unsupported_languages: Vec<_> = language_entries
132        .iter()
133        .filter_map(|(canonical, language)| {
134            if is_supported_language(language, &supported_keys) {
135                None
136            } else {
137                Some(canonical.clone())
138            }
139        })
140        .collect();
141
142    if !unsupported_languages.is_empty() {
143        let formatted = unsupported_languages.join(", ");
144        abort!(enum_span, "unsupported languages in assets: {}.", formatted);
145    }
146
147    let mut variant_idents = Vec::with_capacity(language_entries.len());
148    let mut language_literals = Vec::with_capacity(language_entries.len());
149    let mut fallback_variant_ident = None;
150
151    input_enum
152        .attrs
153        .push(parse_quote!(#[fluent(resource = "es-fluent-lang")]));
154
155    input_enum.variants.clear();
156
157    for (canonical, _language) in &language_entries {
158        let variant_name = canonical.replace('-', "_").to_upper_camel_case();
159
160        let variant_ident = syn::Ident::new(&variant_name, Span::call_site());
161        let literal = LitStr::new(canonical, Span::call_site());
162
163        let attr = parse_quote!(#[fluent(key = #literal)]);
164        let variant = Variant {
165            attrs: vec![attr],
166            ident: variant_ident.clone(),
167            fields: Fields::Unit,
168            discriminant: None,
169        };
170
171        input_enum.variants.push(variant);
172        variant_idents.push(variant_ident.clone());
173        language_literals.push(literal);
174
175        if canonical == &fallback_canonical {
176            fallback_variant_ident = Some(variant_idents.last().unwrap().clone());
177        }
178    }
179
180    let fallback_variant_ident = match fallback_variant_ident {
181        Some(ident) => ident,
182        None => abort!(
183            enum_span,
184            "fallback language was not found among available languages"
185        ),
186    };
187
188    let language_literals_for_ref = language_literals.clone();
189    let variant_idents_for_ref = variant_idents.clone();
190
191    let expanded = quote! {
192        #input_enum
193
194        impl From<#enum_ident> for es_fluent::unic_langid::LanguageIdentifier {
195            fn from(val: #enum_ident) -> Self {
196                match val {
197                    #( #enum_ident::#variant_idents => es_fluent::unic_langid::langid!(#language_literals), )*
198                }
199            }
200        }
201
202        impl From<&#enum_ident> for es_fluent::unic_langid::LanguageIdentifier {
203            fn from(val: &#enum_ident) -> Self {
204                match val {
205                    #( #enum_ident::#variant_idents_for_ref => es_fluent::unic_langid::langid!(#language_literals_for_ref), )*
206                }
207            }
208        }
209
210        impl From<&es_fluent::unic_langid::LanguageIdentifier> for #enum_ident {
211            fn from(lang: &es_fluent::unic_langid::LanguageIdentifier) -> Self {
212                let lang_str = lang.to_string();
213                match lang_str.as_str() {
214                    #( #language_literals_for_ref => #enum_ident::#variant_idents, )*
215                    _ => panic!("Unsupported language identifier: {}", lang),
216                }
217            }
218        }
219
220        impl Default for #enum_ident {
221            fn default() -> Self {
222                #enum_ident::#fallback_variant_ident
223            }
224        }
225    };
226
227    expanded.into()
228}