rustc_fluent_macro/
fluent.rs

1use std::collections::{HashMap, HashSet};
2use std::fs::read_to_string;
3use std::path::{Path, PathBuf};
4
5use annotate_snippets::{Renderer, Snippet};
6use fluent_bundle::{FluentBundle, FluentError, FluentResource};
7use fluent_syntax::ast::{
8    Attribute, Entry, Expression, Identifier, InlineExpression, Message, Pattern, PatternElement,
9};
10use fluent_syntax::parser::ParserError;
11use proc_macro::tracked_path::path;
12use proc_macro::{Diagnostic, Level, Span};
13use proc_macro2::TokenStream;
14use quote::quote;
15use syn::{Ident, LitStr, parse_macro_input};
16use unic_langid::langid;
17
18/// Helper function for returning an absolute path for macro-invocation relative file paths.
19///
20/// If the input is already absolute, then the input is returned. If the input is not absolute,
21/// then it is appended to the directory containing the source file with this macro invocation.
22fn invocation_relative_path_to_absolute(span: Span, path: &str) -> PathBuf {
23    let path = Path::new(path);
24    if path.is_absolute() {
25        path.to_path_buf()
26    } else {
27        // `/a/b/c/foo/bar.rs` contains the current macro invocation
28        let mut source_file_path = span.source_file().path();
29        // `/a/b/c/foo/`
30        source_file_path.pop();
31        // `/a/b/c/foo/../locales/en-US/example.ftl`
32        source_file_path.push(path);
33        source_file_path
34    }
35}
36
37/// Final tokens.
38fn finish(body: TokenStream, resource: TokenStream) -> proc_macro::TokenStream {
39    quote! {
40        /// Raw content of Fluent resource for this crate, generated by `fluent_messages` macro,
41        /// imported by `rustc_driver` to include all crates' resources in one bundle.
42        pub static DEFAULT_LOCALE_RESOURCE: &'static str = #resource;
43
44        #[allow(non_upper_case_globals)]
45        #[doc(hidden)]
46        /// Auto-generated constants for type-checked references to Fluent messages.
47        pub(crate) mod fluent_generated {
48            #body
49
50            /// Constants expected to exist by the diagnostic derive macros to use as default Fluent
51            /// identifiers for different subdiagnostic kinds.
52            pub mod _subdiag {
53                /// Default for `#[help]`
54                pub const help: rustc_errors::SubdiagMessage =
55                    rustc_errors::SubdiagMessage::FluentAttr(std::borrow::Cow::Borrowed("help"));
56                /// Default for `#[note]`
57                pub const note: rustc_errors::SubdiagMessage =
58                    rustc_errors::SubdiagMessage::FluentAttr(std::borrow::Cow::Borrowed("note"));
59                /// Default for `#[warn]`
60                pub const warn: rustc_errors::SubdiagMessage =
61                    rustc_errors::SubdiagMessage::FluentAttr(std::borrow::Cow::Borrowed("warn"));
62                /// Default for `#[label]`
63                pub const label: rustc_errors::SubdiagMessage =
64                    rustc_errors::SubdiagMessage::FluentAttr(std::borrow::Cow::Borrowed("label"));
65                /// Default for `#[suggestion]`
66                pub const suggestion: rustc_errors::SubdiagMessage =
67                    rustc_errors::SubdiagMessage::FluentAttr(std::borrow::Cow::Borrowed("suggestion"));
68            }
69        }
70    }
71    .into()
72}
73
74/// Tokens to be returned when the macro cannot proceed.
75fn failed(crate_name: &Ident) -> proc_macro::TokenStream {
76    finish(quote! { pub mod #crate_name {} }, quote! { "" })
77}
78
79/// See [rustc_fluent_macro::fluent_messages].
80pub(crate) fn fluent_messages(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
81    let crate_name = std::env::var("CARGO_PKG_NAME")
82        // If `CARGO_PKG_NAME` is missing, then we're probably running in a test, so use
83        // `no_crate`.
84        .unwrap_or_else(|_| "no_crate".to_string())
85        .replace("rustc_", "");
86
87    // Cannot iterate over individual messages in a bundle, so do that using the
88    // `FluentResource` instead. Construct a bundle anyway to find out if there are conflicting
89    // messages in the resources.
90    let mut bundle = FluentBundle::new(vec![langid!("en-US")]);
91
92    // Set of Fluent attribute names already output, to avoid duplicate type errors - any given
93    // constant created for a given attribute is the same.
94    let mut previous_attrs = HashSet::new();
95
96    let resource_str = parse_macro_input!(input as LitStr);
97    let resource_span = resource_str.span().unwrap();
98    let relative_ftl_path = resource_str.value();
99    let absolute_ftl_path = invocation_relative_path_to_absolute(resource_span, &relative_ftl_path);
100
101    let crate_name = Ident::new(&crate_name, resource_str.span());
102
103    path(absolute_ftl_path.to_str().unwrap());
104    let resource_contents = match read_to_string(absolute_ftl_path) {
105        Ok(resource_contents) => resource_contents,
106        Err(e) => {
107            Diagnostic::spanned(
108                resource_span,
109                Level::Error,
110                format!("could not open Fluent resource: {e}"),
111            )
112            .emit();
113            return failed(&crate_name);
114        }
115    };
116    let mut bad = false;
117    for esc in ["\\n", "\\\"", "\\'"] {
118        for _ in resource_contents.matches(esc) {
119            bad = true;
120            Diagnostic::spanned(resource_span, Level::Error, format!("invalid escape `{esc}` in Fluent resource"))
121                .note("Fluent does not interpret these escape sequences (<https://projectfluent.org/fluent/guide/special.html>)")
122                .emit();
123        }
124    }
125    if bad {
126        return failed(&crate_name);
127    }
128
129    let resource = match FluentResource::try_new(resource_contents) {
130        Ok(resource) => resource,
131        Err((this, errs)) => {
132            Diagnostic::spanned(resource_span, Level::Error, "could not parse Fluent resource")
133                .help("see additional errors emitted")
134                .emit();
135            for ParserError { pos, slice: _, kind } in errs {
136                let mut err = kind.to_string();
137                // Entirely unnecessary string modification so that the error message starts
138                // with a lowercase as rustc errors do.
139                err.replace_range(0..1, &err.chars().next().unwrap().to_lowercase().to_string());
140
141                let message = annotate_snippets::Level::Error.title(&err).snippet(
142                    Snippet::source(this.source())
143                        .origin(&relative_ftl_path)
144                        .fold(true)
145                        .annotation(annotate_snippets::Level::Error.span(pos.start..pos.end - 1)),
146                );
147                let renderer = Renderer::plain();
148                eprintln!("{}\n", renderer.render(message));
149            }
150
151            return failed(&crate_name);
152        }
153    };
154
155    let mut constants = TokenStream::new();
156    let mut previous_defns = HashMap::new();
157    let mut message_refs = Vec::new();
158    for entry in resource.entries() {
159        if let Entry::Message(msg) = entry {
160            let Message { id: Identifier { name }, attributes, value, .. } = msg;
161            let _ = previous_defns.entry(name.to_string()).or_insert(resource_span);
162            if name.contains('-') {
163                Diagnostic::spanned(
164                    resource_span,
165                    Level::Error,
166                    format!("name `{name}` contains a '-' character"),
167                )
168                .help("replace any '-'s with '_'s")
169                .emit();
170            }
171
172            if let Some(Pattern { elements }) = value {
173                for elt in elements {
174                    if let PatternElement::Placeable {
175                        expression:
176                            Expression::Inline(InlineExpression::MessageReference { id, .. }),
177                    } = elt
178                    {
179                        message_refs.push((id.name, *name));
180                    }
181                }
182            }
183
184            // `typeck_foo_bar` => `foo_bar` (in `typeck.ftl`)
185            // `const_eval_baz` => `baz` (in `const_eval.ftl`)
186            // `const-eval-hyphen-having` => `hyphen_having` (in `const_eval.ftl`)
187            // The last case we error about above, but we want to fall back gracefully
188            // so that only the error is being emitted and not also one about the macro
189            // failing.
190            let crate_prefix = format!("{crate_name}_");
191
192            let snake_name = name.replace('-', "_");
193            if !snake_name.starts_with(&crate_prefix) {
194                Diagnostic::spanned(
195                    resource_span,
196                    Level::Error,
197                    format!("name `{name}` does not start with the crate name"),
198                )
199                .help(format!(
200                    "prepend `{crate_prefix}` to the slug name: `{crate_prefix}{snake_name}`"
201                ))
202                .emit();
203            };
204            let snake_name = Ident::new(&snake_name, resource_str.span());
205
206            if !previous_attrs.insert(snake_name.clone()) {
207                continue;
208            }
209
210            let docstr =
211                format!("Constant referring to Fluent message `{name}` from `{crate_name}`");
212            constants.extend(quote! {
213                #[doc = #docstr]
214                pub const #snake_name: rustc_errors::DiagMessage =
215                    rustc_errors::DiagMessage::FluentIdentifier(
216                        std::borrow::Cow::Borrowed(#name),
217                        None
218                    );
219            });
220
221            for Attribute { id: Identifier { name: attr_name }, .. } in attributes {
222                let snake_name = Ident::new(
223                    &format!("{crate_prefix}{}", attr_name.replace('-', "_")),
224                    resource_str.span(),
225                );
226                if !previous_attrs.insert(snake_name.clone()) {
227                    continue;
228                }
229
230                if attr_name.contains('-') {
231                    Diagnostic::spanned(
232                        resource_span,
233                        Level::Error,
234                        format!("attribute `{attr_name}` contains a '-' character"),
235                    )
236                    .help("replace any '-'s with '_'s")
237                    .emit();
238                }
239
240                let msg = format!(
241                    "Constant referring to Fluent message `{name}.{attr_name}` from `{crate_name}`"
242                );
243                constants.extend(quote! {
244                    #[doc = #msg]
245                    pub const #snake_name: rustc_errors::SubdiagMessage =
246                        rustc_errors::SubdiagMessage::FluentAttr(std::borrow::Cow::Borrowed(#attr_name));
247                });
248            }
249
250            // Record variables referenced by these messages so we can produce
251            // tests in the derive diagnostics to validate them.
252            let ident = quote::format_ident!("{snake_name}_refs");
253            let vrefs = variable_references(msg);
254            constants.extend(quote! {
255                #[cfg(test)]
256                pub const #ident: &[&str] = &[#(#vrefs),*];
257            })
258        }
259    }
260
261    for (mref, name) in message_refs.into_iter() {
262        if !previous_defns.contains_key(mref) {
263            Diagnostic::spanned(
264                resource_span,
265                Level::Error,
266                format!("referenced message `{mref}` does not exist (in message `{name}`)"),
267            )
268            .help(&format!("you may have meant to use a variable reference (`{{${mref}}}`)"))
269            .emit();
270        }
271    }
272
273    if let Err(errs) = bundle.add_resource(resource) {
274        for e in errs {
275            match e {
276                FluentError::Overriding { kind, id } => {
277                    Diagnostic::spanned(
278                        resource_span,
279                        Level::Error,
280                        format!("overrides existing {kind}: `{id}`"),
281                    )
282                    .emit();
283                }
284                FluentError::ResolverError(_) | FluentError::ParserError(_) => unreachable!(),
285            }
286        }
287    }
288
289    finish(constants, quote! { include_str!(#relative_ftl_path) })
290}
291
292fn variable_references<'a>(msg: &Message<&'a str>) -> Vec<&'a str> {
293    let mut refs = vec![];
294    if let Some(Pattern { elements }) = &msg.value {
295        for elt in elements {
296            if let PatternElement::Placeable {
297                expression: Expression::Inline(InlineExpression::VariableReference { id }),
298            } = elt
299            {
300                refs.push(id.name);
301            }
302        }
303    }
304    for attr in &msg.attributes {
305        for elt in &attr.value.elements {
306            if let PatternElement::Placeable {
307                expression: Expression::Inline(InlineExpression::VariableReference { id }),
308            } = elt
309            {
310                refs.push(id.name);
311            }
312        }
313    }
314    refs
315}