zng_ext_l10n_proc_macros/
l10n.rs

1use std::{collections::HashSet, fmt::Write as _};
2
3use proc_macro2::TokenStream;
4use quote::{quote, quote_spanned};
5use syn::*;
6
7use crate::util::Errors;
8
9pub fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
10    let input = parse_macro_input!(input as Input);
11    let message = input.message.value();
12
13    let mut errors = Errors::default();
14
15    let message_params = parse_validate_id(input.message_id, &mut errors);
16
17    let mut fluent_msg;
18    let mut variables = HashSet::new();
19
20    if message.is_empty() {
21        errors.push("message cannot be empty", input.message.span());
22    } else {
23        fluent_msg = "id = ".to_owned();
24        let mut spacing = "";
25        for line in message.lines() {
26            writeln!(&mut fluent_msg, "{spacing}{line}").unwrap();
27            spacing = "   ";
28        }
29        match fluent_syntax::parser::parse_runtime(fluent_msg.as_str()) {
30            Ok(ast) => {
31                let span = input.message.span();
32                if ast.body.len() > 1 {
33                    match &ast.body[1] {
34                        fluent_syntax::ast::Entry::Message(m) => {
35                            errors.push(format!("unescaped fluent message `{}..`", m.id.name), span);
36                        }
37                        fluent_syntax::ast::Entry::Term(t) => {
38                            errors.push(format!("unescaped fluent term `-{}..`", t.id.name), span);
39                        }
40                        fluent_syntax::ast::Entry::Comment(_c)
41                        | fluent_syntax::ast::Entry::GroupComment(_c)
42                        | fluent_syntax::ast::Entry::ResourceComment(_c) => {
43                            errors.push("unescaped fluent comment `#..`", span);
44                        }
45                        fluent_syntax::ast::Entry::Junk { content } => {
46                            errors.push(format!("unexpected `{content}`"), span);
47                        }
48                    }
49                } else {
50                    match &ast.body[0] {
51                        fluent_syntax::ast::Entry::Message(m) => {
52                            if m.id.name != "id" {
53                                non_user_error!("")
54                            }
55                            if m.comment.is_some() {
56                                non_user_error!("")
57                            }
58
59                            if let Some(m) = &m.value {
60                                collect_vars_pattern(&mut errors, &mut variables, m);
61                            }
62                            if !m.attributes.is_empty() {
63                                errors.push(format!("unescaped fluent attribute `.{}..`", m.attributes[0].id.name), span);
64                            }
65                        }
66                        fluent_syntax::ast::Entry::Term(t) => {
67                            errors.push(format!("unescaped fluent term `-{}..`", t.id.name), span);
68                        }
69                        fluent_syntax::ast::Entry::Comment(_c)
70                        | fluent_syntax::ast::Entry::GroupComment(_c)
71                        | fluent_syntax::ast::Entry::ResourceComment(_c) => {
72                            errors.push("unescaped fluent comment `#..`", span);
73                        }
74                        fluent_syntax::ast::Entry::Junk { content } => {
75                            errors.push(format!("unexpected `{content}`"), span);
76                        }
77                    }
78                }
79            }
80            Err((_, e)) => {
81                for e in e {
82                    errors.push(e, input.message.span());
83                }
84            }
85        }
86    }
87
88    if errors.is_empty() {
89        let l10n_path = &input.l10n_path;
90        let message = &input.message;
91        let span = input.message.span();
92
93        let mut build = quote_spanned! {span=>
94            #l10n_path::L10N.l10n_message(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"), #message_params, #message)
95        };
96        for var in variables {
97            let var_ident = ident_spanned!(span=> "{}", var);
98            build.extend(quote_spanned! {span=>
99                .l10n_arg(#var, {
100                    use #l10n_path::IntoL10nVar;
101                    (&mut &mut #l10n_path::L10nSpecialize(Some(#var_ident))).to_l10n_var()
102                })
103            });
104        }
105        build.extend(quote! {
106            .build()
107        });
108
109        build.into()
110    } else {
111        quote! {
112            #errors
113        }
114        .into()
115    }
116}
117
118fn collect_vars_pattern<'s>(errors: &mut Errors, vars: &mut HashSet<&'s str>, pattern: &fluent_syntax::ast::Pattern<&'s str>) {
119    for el in &pattern.elements {
120        match el {
121            fluent_syntax::ast::PatternElement::TextElement { .. } => continue,
122            fluent_syntax::ast::PatternElement::Placeable { expression } => collect_vars_expr(errors, vars, expression),
123        }
124    }
125}
126fn collect_vars_expr<'s>(errors: &mut Errors, vars: &mut HashSet<&'s str>, expression: &fluent_syntax::ast::Expression<&'s str>) {
127    match expression {
128        fluent_syntax::ast::Expression::Select { selector, variants } => {
129            collect_vars_inline_expr(errors, vars, selector);
130            for v in variants {
131                collect_vars_pattern(errors, vars, &v.value);
132            }
133        }
134        fluent_syntax::ast::Expression::Inline(expr) => collect_vars_inline_expr(errors, vars, expr),
135    }
136}
137fn collect_vars_inline_expr<'s>(errors: &mut Errors, vars: &mut HashSet<&'s str>, inline: &fluent_syntax::ast::InlineExpression<&'s str>) {
138    match inline {
139        fluent_syntax::ast::InlineExpression::FunctionReference { arguments, .. } => {
140            for arg in &arguments.positional {
141                collect_vars_inline_expr(errors, vars, arg);
142            }
143            for arg in &arguments.named {
144                collect_vars_inline_expr(errors, vars, &arg.value);
145            }
146        }
147        fluent_syntax::ast::InlineExpression::VariableReference { id } => {
148            vars.insert(id.name);
149        }
150        fluent_syntax::ast::InlineExpression::Placeable { expression } => collect_vars_expr(errors, vars, expression),
151        _ => {}
152    }
153}
154
155struct Input {
156    l10n_path: TokenStream,
157    message_id: LitStr,
158    message: LitStr,
159}
160impl parse::Parse for Input {
161    fn parse(input: parse::ParseStream) -> Result<Self> {
162        Ok(Input {
163            l10n_path: non_user_braced!(input, "l10n_path").parse().unwrap(),
164            message_id: non_user_braced!(input, "message_id").parse()?,
165            message: non_user_braced!(input, "message").parse()?,
166        })
167    }
168}
169
170// Returns "file", "id", "attribute"
171fn parse_validate_id(message_id: LitStr, errors: &mut Errors) -> TokenStream {
172    let s = message_id.value();
173    let span = message_id.span();
174
175    let mut id = s.as_str();
176    let mut file = "";
177    let mut attribute = "";
178    if let Some((f, rest)) = id.rsplit_once('/') {
179        file = f;
180        id = rest;
181    }
182    if let Some((i, a)) = id.rsplit_once('.') {
183        id = i;
184        attribute = a;
185    }
186
187    // file
188    if !file.is_empty() {
189        let mut first = true;
190        let mut valid = true;
191        let path: &std::path::Path = file.as_ref();
192        for c in path.components() {
193            if !first || !matches!(c, std::path::Component::Normal(_)) {
194                valid = false;
195                break;
196            }
197            first = false;
198        }
199        if !valid {
200            errors.push(format!("invalid file {file:?}, must be a single file name"), span);
201            file = "";
202        }
203    }
204
205    // https://github.com/projectfluent/fluent/blob/master/spec/fluent.ebnf
206    // Identifier ::= [a-zA-Z] [a-zA-Z0-9_-]*
207    fn validate(value: &str) -> bool {
208        let mut first = true;
209        if !value.is_empty() {
210            for c in value.chars() {
211                if !first && (c == '_' || c == '-' || c.is_ascii_digit()) {
212                    continue;
213                }
214                if !c.is_ascii_lowercase() && !c.is_ascii_uppercase() {
215                    return false;
216                }
217
218                first = false;
219            }
220        } else {
221            return false;
222        }
223        true
224    }
225    if !validate(id) {
226        errors.push(
227            format!("invalid id {id:?}, must start with letter, followed by any letters, digits, `_` or `-`"),
228            span,
229        );
230        id = "invalid__";
231    }
232    if !attribute.is_empty() && !validate(attribute) {
233        errors.push(
234            format!("invalid attribute {attribute:?}, must start with letter, followed by any letters, digits, `_` or `-`"),
235            span,
236        );
237        attribute = "";
238    }
239
240    if !attribute.is_empty() {
241        if let Err((_, e)) = fluent_syntax::parser::parse_runtime(format!("{id} = \n .{attribute} = m")) {
242            for e in e {
243                errors.push(e, span);
244            }
245        }
246    } else if let Err((_, e)) = fluent_syntax::parser::parse_runtime(format!("{id} = m")) {
247        for e in e {
248            errors.push(e, span);
249        }
250    }
251
252    quote_spanned!(span=> #file, #id, #attribute)
253}