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