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
170fn 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 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 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}