Skip to main content

laterite_macros/
lib.rs

1//! Translation-string macros.
2//!
3//! `t!`, `tn!`, `tp!` build a `laterite_core::i18n::Text` at the call site. The
4//! source is a string literal (so extraction can collect it), its `{name}`
5//! placeholders are checked against the named arguments at compile time, and
6//! positional `{}` or format specs are rejected. For a non-literal source, use
7//! `Text::dynamic`.
8
9use proc_macro::TokenStream;
10use proc_macro2::Span;
11use quote::quote;
12use syn::parse::{Parse, ParseStream};
13use syn::{parse_macro_input, Expr, Ident, LitStr, Token};
14
15/// A `name = expr` argument.
16#[derive(Clone)]
17struct KwArg {
18    name: Ident,
19    expr: Expr,
20}
21
22impl Parse for KwArg {
23    fn parse(input: ParseStream) -> syn::Result<Self> {
24        let name = input.parse()?;
25        input.parse::<Token![=]>()?;
26        let expr = input.parse()?;
27        Ok(Self { name, expr })
28    }
29}
30
31/// Parses trailing `, name = expr` arguments; a trailing comma is allowed.
32fn trailing_kwargs(input: ParseStream) -> syn::Result<Vec<KwArg>> {
33    let mut kwargs = Vec::new();
34    while input.peek(Token![,]) {
35        input.parse::<Token![,]>()?;
36        if input.is_empty() {
37            break;
38        }
39        kwargs.push(input.parse()?);
40    }
41    Ok(kwargs)
42}
43
44struct T {
45    source: LitStr,
46    kwargs: Vec<KwArg>,
47}
48impl Parse for T {
49    fn parse(input: ParseStream) -> syn::Result<Self> {
50        let source = input.parse()?;
51        Ok(Self {
52            source,
53            kwargs: trailing_kwargs(input)?,
54        })
55    }
56}
57
58struct Tp {
59    context: LitStr,
60    source: LitStr,
61    kwargs: Vec<KwArg>,
62}
63impl Parse for Tp {
64    fn parse(input: ParseStream) -> syn::Result<Self> {
65        let context = input.parse()?;
66        input.parse::<Token![,]>()?;
67        let source = input.parse()?;
68        Ok(Self {
69            context,
70            source,
71            kwargs: trailing_kwargs(input)?,
72        })
73    }
74}
75
76struct Tn {
77    one: LitStr,
78    other: LitStr,
79    kwargs: Vec<KwArg>,
80}
81impl Parse for Tn {
82    fn parse(input: ParseStream) -> syn::Result<Self> {
83        let one = input.parse()?;
84        input.parse::<Token![,]>()?;
85        let other = input.parse()?;
86        Ok(Self {
87            one,
88            other,
89            kwargs: trailing_kwargs(input)?,
90        })
91    }
92}
93
94fn compile_error(span: Span, msg: &str) -> TokenStream {
95    syn::Error::new(span, msg).to_compile_error().into()
96}
97
98fn is_ident(s: &str) -> bool {
99    let mut chars = s.chars();
100    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
101        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
102}
103
104/// The ordered, unique `{name}` placeholders in `s`, or an error message. `{{` and
105/// `}}` are literal braces; `{}` and `{name:spec}` are rejected.
106fn placeholders(s: &str) -> Result<Vec<String>, String> {
107    let mut out: Vec<String> = Vec::new();
108    let mut chars = s.chars().peekable();
109    while let Some(c) = chars.next() {
110        match c {
111            '{' => {
112                if chars.peek() == Some(&'{') {
113                    chars.next();
114                    continue;
115                }
116                let mut name = String::new();
117                let mut closed = false;
118                for ch in chars.by_ref() {
119                    if ch == '}' {
120                        closed = true;
121                        break;
122                    }
123                    name.push(ch);
124                }
125                if !closed {
126                    return Err("unmatched '{' in a translation string".into());
127                }
128                if name.is_empty() {
129                    return Err("positional '{}' is not allowed; name the placeholder".into());
130                }
131                if name.contains(':') {
132                    return Err(format!("format spec in '{{{name}}}' is not allowed"));
133                }
134                if !is_ident(&name) {
135                    return Err(format!("'{{{name}}}' is not a valid placeholder name"));
136                }
137                if !out.iter().any(|p| p == &name) {
138                    out.push(name);
139                }
140            }
141            '}' => {
142                if chars.peek() == Some(&'}') {
143                    chars.next();
144                } else {
145                    return Err(
146                        "unmatched '}' in a translation string; use '}}' for a literal".into(),
147                    );
148                }
149            }
150            _ => {}
151        }
152    }
153    Ok(out)
154}
155
156/// Every placeholder needs an argument, and every argument must be used by a
157/// placeholder except names listed in `extra` (`tn!`'s count `n`).
158fn check(placeholders: &[String], names: &[String], extra: &[&str]) -> Result<(), String> {
159    for p in placeholders {
160        if !names.iter().any(|n| n == p) {
161            return Err(format!(
162                "placeholder '{{{p}}}' has no matching argument '{p} = ...'"
163            ));
164        }
165    }
166    for n in names {
167        if !placeholders.iter().any(|p| p == n) && !extra.contains(&n.as_str()) {
168            return Err(format!(
169                "argument '{n}' is not used by any '{{{n}}}' placeholder"
170            ));
171        }
172    }
173    Ok(())
174}
175
176fn arg_names(kwargs: &[KwArg]) -> Vec<String> {
177    kwargs.iter().map(|k| k.name.to_string()).collect()
178}
179
180fn arg_calls(kwargs: &[KwArg]) -> Vec<proc_macro2::TokenStream> {
181    kwargs
182        .iter()
183        .map(|k| {
184            let key = k.name.to_string();
185            let expr = &k.expr;
186            quote! { .arg(#key, #expr) }
187        })
188        .collect()
189}
190
191/// A translation message: `t!("Save")`, `t!("Welcome, {name}", name = user.name)`.
192#[proc_macro]
193pub fn t(input: TokenStream) -> TokenStream {
194    let T { source, kwargs } = parse_macro_input!(input as T);
195    let ph = match placeholders(&source.value()) {
196        Ok(p) => p,
197        Err(e) => return compile_error(source.span(), &e),
198    };
199    if let Err(e) = check(&ph, &arg_names(&kwargs), &[]) {
200        return compile_error(source.span(), &e);
201    }
202    let args = arg_calls(&kwargs);
203    quote! { ::laterite_core::i18n::Text::new(#source) #(#args)* }.into()
204}
205
206/// A context-disambiguated message (homonyms): `tp!("verb", "Open")`.
207#[proc_macro]
208pub fn tp(input: TokenStream) -> TokenStream {
209    let Tp {
210        context,
211        source,
212        kwargs,
213    } = parse_macro_input!(input as Tp);
214    let ph = match placeholders(&source.value()) {
215        Ok(p) => p,
216        Err(e) => return compile_error(source.span(), &e),
217    };
218    if let Err(e) = check(&ph, &arg_names(&kwargs), &[]) {
219        return compile_error(source.span(), &e);
220    }
221    let args = arg_calls(&kwargs);
222    quote! { ::laterite_core::i18n::Text::new(#source).with_context(#context) #(#args)* }.into()
223}
224
225/// A pluralized message: `tn!("{n} item", "{n} items", n = count)`. The `n`
226/// argument is the count the plural form is chosen from.
227#[proc_macro]
228pub fn tn(input: TokenStream) -> TokenStream {
229    let Tn { one, other, kwargs } = parse_macro_input!(input as Tn);
230    let count = match kwargs.iter().find(|k| k.name == "n") {
231        Some(k) => k.expr.clone(),
232        None => return compile_error(one.span(), "tn! needs a count argument, 'n = ...'"),
233    };
234    let mut ph = match placeholders(&one.value()) {
235        Ok(p) => p,
236        Err(e) => return compile_error(one.span(), &e),
237    };
238    match placeholders(&other.value()) {
239        Ok(more) => {
240            for p in more {
241                if !ph.iter().any(|x| x == &p) {
242                    ph.push(p);
243                }
244            }
245        }
246        Err(e) => return compile_error(other.span(), &e),
247    }
248    if let Err(e) = check(&ph, &arg_names(&kwargs), &["n"]) {
249        return compile_error(one.span(), &e);
250    }
251    let others: Vec<KwArg> = kwargs.into_iter().filter(|k| k.name != "n").collect();
252    let others = arg_calls(&others);
253    quote! {
254        {
255            let __n: i64 = (#count) as i64;
256            ::laterite_core::i18n::Text::new(#one)
257                .with_plural(#other, __n)
258                .arg("n", __n)
259                #(#others)*
260        }
261    }
262    .into()
263}