flux_macros/diagnostics/
fluent.rs

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