fluent_static_macros/
lib.rs

1use std::{collections::HashMap, env, ffi::OsString};
2
3use fluent_static_codegen::{
4    function::{FunctionCallGenerator, FunctionRegistry},
5    MessageBundleBuilder,
6};
7use proc_macro::TokenStream;
8use proc_macro2::{Span, TokenStream as TokenStream2};
9use quote::{format_ident, quote};
10use syn::{
11    parse::Parse, parse_macro_input, punctuated::Punctuated, spanned::Spanned, token::Comma, Ident,
12    ItemStruct, LitStr, Result as SyntaxResult, Token,
13};
14
15macro_rules! syntax_err {
16    ($input:expr, $message:expr $(, $args:expr)*) => {
17        ::syn::Error::new($input, format!($message $(, $args)*))
18    }
19}
20
21#[proc_macro_attribute]
22pub fn message_bundle(args: TokenStream, input: TokenStream) -> TokenStream {
23    let item_struct = parse_macro_input!(input as ItemStruct);
24    let name = item_struct.ident.to_string();
25    let MessageBundleAttr {
26        mut builder,
27        includes,
28    } = parse_macro_input!(args as MessageBundleAttr);
29    builder.set_bundle_name(&name);
30    match builder.build() {
31        Ok(result) => {
32            let tokens = result.tokens();
33            let includes: Vec<TokenStream2> = includes
34                .iter()
35                .map(|path| {
36                    quote! {
37                        #[cfg(trybuild)]
38                        const _: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR_OVERRIDE"), "/", #path));
39                        #[cfg(not(trybuild))]
40                        const _: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", #path));
41                    }
42                })
43                .collect();
44            TokenStream::from(quote! {
45                #(#includes)*
46                #tokens
47            })
48        }
49        Err(e) => syntax_err!(item_struct.span(), "Error generating message bundle: {}", e)
50            .to_compile_error()
51            .into(),
52    }
53}
54
55fn get_project_dir() -> Option<OsString> {
56    env::var_os("CARGO_MANIFEST_DIR_OVERRIDE") // used for tests
57        .or_else(|| env::var_os("CARGO_MANIFEST_DIR"))
58}
59
60struct MessageBundleAttr {
61    builder: MessageBundleBuilder,
62    includes: Vec<String>,
63}
64
65struct FluentResource {
66    path: String,
67    language: String,
68    span: Span,
69}
70
71impl Parse for FluentResource {
72    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
73        let span = input.span();
74        let content;
75        syn::parenthesized!(content in input);
76        let path: String = content.parse::<LitStr>()?.value();
77        content.parse::<Token![,]>()?;
78        let language: String = content.parse::<LitStr>()?.value();
79        Ok(FluentResource {
80            path,
81            language,
82            span,
83        })
84    }
85}
86
87struct FunctionMapping {
88    fluent_id: LitStr,
89    fn_ident: Option<Ident>,
90}
91
92impl Parse for FunctionMapping {
93    fn parse(input: syn::parse::ParseStream) -> SyntaxResult<Self> {
94        let fluent_id = input.parse::<LitStr>()?;
95        let fn_ident = if input.peek(Token![=]) {
96            input.parse::<Token![=]>()?;
97            Some(input.parse::<Ident>()?)
98        } else {
99            None
100        };
101        Ok(FunctionMapping {
102            fluent_id,
103            fn_ident,
104        })
105    }
106}
107
108impl Parse for MessageBundleAttr {
109    fn parse(input: syn::parse::ParseStream) -> SyntaxResult<Self> {
110        let base_dir = get_project_dir()
111            .ok_or_else(|| syntax_err!(input.span(), "Unable to get project directory"))?;
112
113        let mut fluent_resources: Vec<FluentResource> = Vec::new();
114        let mut function_mappings: Vec<FunctionMapping> = Vec::new();
115        let mut lang_def: Option<LitStr> = None;
116        let mut formatter: Option<LitStr> = None;
117
118        while !input.is_empty() {
119            let ident: Ident = input.parse()?;
120            input.parse::<Token![=]>()?;
121
122            match ident.to_string().as_str() {
123                "resources" => {
124                    let resource_list;
125                    syn::bracketed!(resource_list in input);
126                    let resources: Punctuated<FluentResource, Comma> =
127                        resource_list.parse_terminated(FluentResource::parse, Token![,])?;
128                    fluent_resources.extend(resources);
129                }
130                "default_language" => {
131                    lang_def = Some(input.parse()?);
132                }
133                "functions" => {
134                    let content;
135                    syn::parenthesized!(content in input);
136                    let fn_mappings: Punctuated<FunctionMapping, Comma> =
137                        content.parse_terminated(FunctionMapping::parse, Token![,])?;
138                    function_mappings.extend(fn_mappings);
139                }
140                "formatter" => {
141                    formatter = Some(input.parse()?);
142                }
143                attr => return Err(syntax_err!(ident.span(), "Unexpected attribute {attr}")),
144            }
145
146            if !input.is_empty() {
147                input.parse::<Token![,]>()?;
148            }
149        }
150
151        if fluent_resources.is_empty() {
152            Err(syntax_err!(
153                input.span(),
154                "No Fluent resources defined. Missing or empty 'resources' attribute"
155            ))
156        } else if lang_def.is_none() {
157            Err(syntax_err!(
158                input.span(),
159                "No default/fallback language is set. Missing 'default_language' attribute"
160            ))
161        } else {
162            let mut builder = MessageBundleBuilder::default();
163            let mut includes = Vec::new();
164
165            builder
166                .set_resources_dir(base_dir)
167                .set_default_language(&lang_def.unwrap().value())
168                .map_err(|e| syntax_err!(input.span(), "Error parsing default language: {}", e))?;
169
170            if let Some(formatter_fn) = formatter {
171                builder
172                    .set_message_formatter_fn(&formatter_fn.value())
173                    .map_err(|e| {
174                        syntax_err!(
175                            formatter_fn.span(),
176                            "Error parsing formatter definition: {}",
177                            e
178                        )
179                    })?;
180            }
181
182            if !function_mappings.is_empty() {
183                builder.set_function_call_generator(BundleFunctionCallGenerator::new(
184                    function_mappings,
185                ));
186            }
187
188            for resource in fluent_resources {
189                builder
190                    .add_resource(&resource.language, &resource.path)
191                    .map_err(|e| syntax_err!(resource.span, "Error processing resource: {}", e))?;
192                includes.push(resource.path);
193            }
194
195            Ok(MessageBundleAttr { builder, includes })
196        }
197    }
198}
199
200struct BundleFunctionCallGenerator {
201    fns: HashMap<String, TokenStream2>,
202    registry: FunctionRegistry,
203}
204
205impl BundleFunctionCallGenerator {
206    pub fn new(fn_mappings: Vec<FunctionMapping>) -> Self {
207        let fns = fn_mappings
208            .into_iter()
209            .map(|mapping| {
210                let ident = mapping
211                    .fn_ident
212                    .unwrap_or_else(|| format_ident!("{}", mapping.fluent_id.value()));
213                (
214                    mapping.fluent_id.value(),
215                    quote! {
216                        #ident
217                    },
218                )
219            })
220            .collect();
221
222        let registry = FunctionRegistry::default();
223
224        Self { fns, registry }
225    }
226}
227
228impl FunctionCallGenerator for BundleFunctionCallGenerator {
229    fn generate(
230        &self,
231        function_name: &str,
232        positional_args: &Ident,
233        named_args: &Ident,
234    ) -> Option<TokenStream2> {
235        if let Some(fn_ident) = self.fns.get(function_name) {
236            Some(quote! {
237                Self::#fn_ident(&#positional_args, &#named_args)
238            })
239        } else {
240            self.registry
241                .generate(function_name, positional_args, named_args)
242        }
243    }
244}