Skip to main content

flux_macros/diagnostics/
fluent.rs

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