Add verification for inline fluent messages

This commit is contained in:
Jonathan Brouwer 2026-01-30 21:23:40 +01:00
parent 523d9d9200
commit ca2be71a18
No known key found for this signature in database
GPG key ID: F13E55D38C971DEF
9 changed files with 183 additions and 73 deletions

View file

@ -4229,6 +4229,8 @@ dependencies = [
name = "rustc_macros"
version = "0.0.0"
dependencies = [
"fluent-bundle",
"fluent-syntax",
"proc-macro2",
"quote",
"syn 2.0.110",

View file

@ -8,6 +8,8 @@ proc-macro = true
[dependencies]
# tidy-alphabetical-start
fluent-bundle = "0.16"
fluent-syntax = "0.12"
proc-macro2 = "1"
quote = "1"
syn = { version = "2.0.9", features = ["full"] }

View file

@ -22,7 +22,7 @@ impl<'a> DiagnosticDerive<'a> {
pub(crate) fn into_tokens(self) -> TokenStream {
let DiagnosticDerive { mut structure } = self;
let kind = DiagnosticDeriveKind::Diagnostic;
let slugs = RefCell::new(Vec::new());
let messages = RefCell::new(Vec::new());
let implementation = kind.each_variant(&mut structure, |mut builder, variant| {
let preamble = builder.preamble(variant);
let body = builder.body(variant);
@ -30,8 +30,8 @@ impl<'a> DiagnosticDerive<'a> {
let Some(message) = builder.primary_message() else {
return DiagnosticDeriveError::ErrorHandled.to_compile_error();
};
slugs.borrow_mut().extend(message.slug().cloned());
let message = message.diag_message();
messages.borrow_mut().push(message.clone());
let message = message.diag_message(variant);
let init = quote! {
let mut diag = rustc_errors::Diag::new(
@ -68,7 +68,7 @@ impl<'a> DiagnosticDerive<'a> {
}
}
});
for test in slugs.borrow().iter().map(|s| generate_test(s, &structure)) {
for test in messages.borrow().iter().map(|s| s.generate_test(&structure)) {
imp.extend(test);
}
imp
@ -88,7 +88,7 @@ impl<'a> LintDiagnosticDerive<'a> {
pub(crate) fn into_tokens(self) -> TokenStream {
let LintDiagnosticDerive { mut structure } = self;
let kind = DiagnosticDeriveKind::LintDiagnostic;
let slugs = RefCell::new(Vec::new());
let messages = RefCell::new(Vec::new());
let implementation = kind.each_variant(&mut structure, |mut builder, variant| {
let preamble = builder.preamble(variant);
let body = builder.body(variant);
@ -96,8 +96,8 @@ impl<'a> LintDiagnosticDerive<'a> {
let Some(message) = builder.primary_message() else {
return DiagnosticDeriveError::ErrorHandled.to_compile_error();
};
slugs.borrow_mut().extend(message.slug().cloned());
let message = message.diag_message();
messages.borrow_mut().push(message.clone());
let message = message.diag_message(variant);
let primary_message = quote! {
diag.primary_message(#message);
};
@ -125,47 +125,10 @@ impl<'a> LintDiagnosticDerive<'a> {
}
}
});
for test in slugs.borrow().iter().map(|s| generate_test(s, &structure)) {
for test in messages.borrow().iter().map(|s| s.generate_test(&structure)) {
imp.extend(test);
}
imp
}
}
/// Generates a `#[test]` that verifies that all referenced variables
/// exist on this structure.
fn generate_test(slug: &syn::Path, structure: &Structure<'_>) -> TokenStream {
// FIXME: We can't identify variables in a subdiagnostic
for field in structure.variants().iter().flat_map(|v| v.ast().fields.iter()) {
for attr_name in field.attrs.iter().filter_map(|at| at.path().get_ident()) {
if attr_name == "subdiagnostic" {
return quote!();
}
}
}
use std::sync::atomic::{AtomicUsize, Ordering};
// We need to make sure that the same diagnostic slug can be used multiple times without
// causing an error, so just have a global counter here.
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let slug = slug.get_ident().unwrap();
let ident = quote::format_ident!("verify_{slug}_{}", COUNTER.fetch_add(1, Ordering::Relaxed));
let ref_slug = quote::format_ident!("{slug}_refs");
let struct_name = &structure.ast().ident;
let variables: Vec<_> = structure
.variants()
.iter()
.flat_map(|v| v.ast().fields.iter().filter_map(|f| f.ident.as_ref().map(|i| i.to_string())))
.collect();
// tidy errors on `#[test]` outside of test files, so we use `#[test ]` to work around this
quote! {
#[cfg(test)]
#[test ]
fn #ident() {
let variables = [#(#variables),*];
for vref in crate::fluent_generated::#ref_slug {
assert!(variables.contains(vref), "{}: variable `{vref}` not found ({})", stringify!(#struct_name), stringify!(#slug));
}
}
}
}

View file

@ -137,7 +137,8 @@ impl DiagnosticDeriveVariantBuilder {
let ast = variant.ast();
let attrs = &ast.attrs;
let preamble = attrs.iter().map(|attr| {
self.generate_structure_code_for_attr(attr).unwrap_or_else(|v| v.to_compile_error())
self.generate_structure_code_for_attr(attr, variant)
.unwrap_or_else(|v| v.to_compile_error())
});
quote! {
@ -155,7 +156,7 @@ impl DiagnosticDeriveVariantBuilder {
}
// ..and then subdiagnostic additions.
for binding in variant.bindings().iter().filter(|bi| !should_generate_arg(bi.ast())) {
body.extend(self.generate_field_attrs_code(binding));
body.extend(self.generate_field_attrs_code(binding, variant));
}
body
}
@ -199,6 +200,7 @@ impl DiagnosticDeriveVariantBuilder {
fn generate_structure_code_for_attr(
&mut self,
attr: &Attribute,
variant: &VariantInfo<'_>,
) -> Result<TokenStream, DiagnosticDeriveError> {
// Always allow documentation comments.
if is_doc_comment(attr) {
@ -224,7 +226,7 @@ impl DiagnosticDeriveVariantBuilder {
)
.emit();
}
self.message = Some(Message::Inline(message.value()));
self.message = Some(Message::Inline(message.span(), message.value()));
} else {
// Parse a slug
let slug = input.parse::<Path>()?;
@ -285,7 +287,7 @@ impl DiagnosticDeriveVariantBuilder {
| SubdiagnosticKind::NoteOnce
| SubdiagnosticKind::Help
| SubdiagnosticKind::HelpOnce
| SubdiagnosticKind::Warn => Ok(self.add_subdiagnostic(&fn_ident, slug)),
| SubdiagnosticKind::Warn => Ok(self.add_subdiagnostic(&fn_ident, slug, variant)),
SubdiagnosticKind::Label | SubdiagnosticKind::Suggestion { .. } => {
throw_invalid_attr!(attr, |diag| diag
.help("`#[label]` and `#[suggestion]` can only be applied to fields"));
@ -313,7 +315,11 @@ impl DiagnosticDeriveVariantBuilder {
}
}
fn generate_field_attrs_code(&mut self, binding_info: &BindingInfo<'_>) -> TokenStream {
fn generate_field_attrs_code(
&mut self,
binding_info: &BindingInfo<'_>,
variant: &VariantInfo<'_>,
) -> TokenStream {
let field = binding_info.ast();
let field_binding = &binding_info.binding;
@ -352,6 +358,7 @@ impl DiagnosticDeriveVariantBuilder {
attr,
FieldInfo { binding: binding_info, ty: inner_ty, span: &field.span() },
binding,
variant
)
.unwrap_or_else(|v| v.to_compile_error());
@ -369,6 +376,7 @@ impl DiagnosticDeriveVariantBuilder {
attr: &Attribute,
info: FieldInfo<'_>,
binding: TokenStream,
variant: &VariantInfo<'_>,
) -> Result<TokenStream, DiagnosticDeriveError> {
let ident = &attr.path().segments.last().unwrap().ident;
let name = ident.to_string();
@ -407,7 +415,7 @@ impl DiagnosticDeriveVariantBuilder {
match subdiag {
SubdiagnosticKind::Label => {
report_error_if_not_applied_to_span(attr, &info)?;
Ok(self.add_spanned_subdiagnostic(binding, &fn_ident, slug))
Ok(self.add_spanned_subdiagnostic(binding, &fn_ident, slug, variant))
}
SubdiagnosticKind::Note
| SubdiagnosticKind::NoteOnce
@ -418,11 +426,11 @@ impl DiagnosticDeriveVariantBuilder {
if type_matches_path(inner, &["rustc_span", "Span"])
|| type_matches_path(inner, &["rustc_span", "MultiSpan"])
{
Ok(self.add_spanned_subdiagnostic(binding, &fn_ident, slug))
Ok(self.add_spanned_subdiagnostic(binding, &fn_ident, slug, variant))
} else if type_is_unit(inner)
|| (matches!(info.ty, FieldInnerTy::Plain(_)) && type_is_bool(inner))
{
Ok(self.add_subdiagnostic(&fn_ident, slug))
Ok(self.add_subdiagnostic(&fn_ident, slug, variant))
} else {
report_type_error(attr, "`Span`, `MultiSpan`, `bool` or `()`")?
}
@ -448,7 +456,7 @@ impl DiagnosticDeriveVariantBuilder {
applicability.set_once(quote! { #static_applicability }, span);
}
let message = slug.diag_message();
let message = slug.diag_message(variant);
let applicability = applicability
.value()
.unwrap_or_else(|| quote! { rustc_errors::Applicability::Unspecified });
@ -476,9 +484,10 @@ impl DiagnosticDeriveVariantBuilder {
field_binding: TokenStream,
kind: &Ident,
message: Message,
variant: &VariantInfo<'_>,
) -> TokenStream {
let fn_name = format_ident!("span_{}", kind);
let message = message.diag_message();
let message = message.diag_message(variant);
quote! {
diag.#fn_name(
#field_binding,
@ -489,8 +498,13 @@ impl DiagnosticDeriveVariantBuilder {
/// Adds a subdiagnostic by generating a `diag.span_$kind` call with the current slug
/// and `fluent_attr_identifier`.
fn add_subdiagnostic(&self, kind: &Ident, message: Message) -> TokenStream {
let message = message.diag_message();
fn add_subdiagnostic(
&self,
kind: &Ident,
message: Message,
variant: &VariantInfo<'_>,
) -> TokenStream {
let message = message.diag_message(variant);
quote! {
diag.#kind(#message);
}

View file

@ -1,28 +1,133 @@
use proc_macro2::TokenStream;
use fluent_bundle::FluentResource;
use fluent_syntax::ast::{Expression, InlineExpression, Pattern, PatternElement};
use proc_macro2::{Span, TokenStream};
use quote::quote;
use syn::Path;
use synstructure::{Structure, VariantInfo};
use crate::diagnostics::error::span_err;
#[derive(Clone)]
pub(crate) enum Message {
Slug(Path),
Inline(String),
Inline(Span, String),
}
impl Message {
pub(crate) fn slug(&self) -> Option<&Path> {
match self {
Message::Slug(slug) => Some(slug),
Message::Inline(_) => None,
}
}
pub(crate) fn diag_message(&self) -> TokenStream {
pub(crate) fn diag_message(&self, variant: &VariantInfo<'_>) -> TokenStream {
match self {
Message::Slug(slug) => {
quote! { crate::fluent_generated::#slug }
}
Message::Inline(message) => {
Message::Inline(message_span, message) => {
verify_fluent_message(*message_span, &message, variant);
quote! { rustc_errors::DiagMessage::Inline(std::borrow::Cow::Borrowed(#message)) }
}
}
}
/// Generates a `#[test]` that verifies that all referenced variables
/// exist on this structure.
pub(crate) fn generate_test(&self, structure: &Structure<'_>) -> TokenStream {
match self {
Message::Slug(slug) => {
// FIXME: We can't identify variables in a subdiagnostic
for field in structure.variants().iter().flat_map(|v| v.ast().fields.iter()) {
for attr_name in field.attrs.iter().filter_map(|at| at.path().get_ident()) {
if attr_name == "subdiagnostic" {
return quote!();
}
}
}
use std::sync::atomic::{AtomicUsize, Ordering};
// We need to make sure that the same diagnostic slug can be used multiple times without
// causing an error, so just have a global counter here.
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let slug = slug.get_ident().unwrap();
let ident = quote::format_ident!(
"verify_{slug}_{}",
COUNTER.fetch_add(1, Ordering::Relaxed)
);
let ref_slug = quote::format_ident!("{slug}_refs");
let struct_name = &structure.ast().ident;
let variables: Vec<_> = structure
.variants()
.iter()
.flat_map(|v| {
v.ast()
.fields
.iter()
.filter_map(|f| f.ident.as_ref().map(|i| i.to_string()))
})
.collect();
// tidy errors on `#[test]` outside of test files, so we use `#[test ]` to work around this
quote! {
#[cfg(test)]
#[test ]
fn #ident() {
let variables = [#(#variables),*];
for vref in crate::fluent_generated::#ref_slug {
assert!(variables.contains(vref), "{}: variable `{vref}` not found ({})", stringify!(#struct_name), stringify!(#slug));
}
}
}
}
Message::Inline(..) => {
// We don't generate a test for inline diagnostics, we can verify these at compile-time!
// This verification is done in the `diag_message` function above
quote! {}
}
}
}
}
fn verify_fluent_message(msg_span: Span, message: &str, variant: &VariantInfo<'_>) {
// Parse the fluent message
const GENERATED_MSG_ID: &str = "generated_msg";
let resource = FluentResource::try_new(format!("{GENERATED_MSG_ID} = {message}\n")).unwrap();
assert_eq!(resource.entries().count(), 1);
let Some(fluent_syntax::ast::Entry::Message(message)) = resource.get_entry(0) else {
panic!("Did not parse into a message")
};
// Check if all variables are used
let fields: Vec<String> = variant
.bindings()
.iter()
.flat_map(|b| b.ast().ident.as_ref())
.map(|id| id.to_string())
.collect();
for variable in variable_references(&message) {
if !fields.iter().any(|f| f == variable) {
span_err(msg_span.unwrap(), format!("Variable `{variable}` not found in diagnostic "))
.help(format!("Available fields: {:?}", fields.join(", ")))
.emit();
}
// assert!(, );
}
}
fn variable_references<'a>(msg: &fluent_syntax::ast::Message<&'a str>) -> Vec<&'a str> {
let mut refs = vec![];
if let Some(Pattern { elements }) = &msg.value {
for elt in elements {
if let PatternElement::Placeable {
expression: Expression::Inline(InlineExpression::VariableReference { id }),
} = elt
{
refs.push(id.name);
}
}
}
for attr in &msg.attributes {
for elt in &attr.value.elements {
if let PatternElement::Placeable {
expression: Expression::Inline(InlineExpression::VariableReference { id }),
} = elt
{
refs.push(id.name);
}
}
}
refs
}

View file

@ -75,7 +75,7 @@ impl SubdiagnosticDerive {
has_subdiagnostic: false,
is_enum,
};
builder.into_tokens().unwrap_or_else(|v| v.to_compile_error())
builder.into_tokens(variant).unwrap_or_else(|v| v.to_compile_error())
});
quote! {
@ -497,7 +497,10 @@ impl<'parent, 'a> SubdiagnosticDeriveVariantBuilder<'parent, 'a> {
}
}
pub(crate) fn into_tokens(&mut self) -> Result<TokenStream, DiagnosticDeriveError> {
pub(crate) fn into_tokens(
&mut self,
variant: &VariantInfo<'_>,
) -> Result<TokenStream, DiagnosticDeriveError> {
let kind_slugs = self.identify_kind()?;
let kind_stats: KindsStatistics = kind_slugs.iter().map(|(kind, _slug)| kind).collect();
@ -535,7 +538,7 @@ impl<'parent, 'a> SubdiagnosticDeriveVariantBuilder<'parent, 'a> {
let mut calls = TokenStream::new();
for (kind, slug) in kind_slugs {
let message = format_ident!("__message");
let message_stream = slug.diag_message();
let message_stream = slug.diag_message(variant);
calls.extend(quote! { let #message = #diag.eagerly_translate(#message_stream); });
let name = format_ident!("{}{}", if span_field.is_some() { "span_" } else { "" }, kind);

View file

@ -708,7 +708,7 @@ impl SubdiagnosticVariant {
}
if !input.is_empty() { input.parse::<Token![,]>()?; }
if is_first {
slug = Some(Message::Inline(message.value()));
slug = Some(Message::Inline(message.span(), message.value()));
is_first = false;
} else {
span_err(message.span().unwrap(), "a diagnostic message must be the first argument to the attribute").emit();

View file

@ -762,3 +762,16 @@ struct SuggestionOnVec {
//~^ ERROR `#[suggestion(...)]` is not a valid attribute
sub: Vec<Span>,
}
#[derive(Diagnostic)]
#[diag("exists: {$sub}")]
struct VariableExists {
sub: String,
}
#[derive(Diagnostic)]
#[diag("does not exist: {$nosub}")]
//~^ ERROR Variable `nosub` not found in diagnostic
struct VariableDoesNotExist {
sub: String,
}

View file

@ -508,6 +508,14 @@ LL | #[suggestion("with a suggestion", code = "")]
= help: to show a suggestion consisting of multiple parts, use a `Subdiagnostic` annotated with `#[multipart_suggestion(...)]`
= help: to show a variable set of suggestions, use a `Vec` of `Subdiagnostic`s annotated with `#[suggestion(...)]`
error: derive(Diagnostic): Variable `nosub` not found in diagnostic
--> $DIR/diagnostic-derive-inline.rs:773:8
|
LL | #[diag("does not exist: {$nosub}")]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= help: Available fields: "sub"
error: cannot find attribute `nonsense` in this scope
--> $DIR/diagnostic-derive-inline.rs:61:3
|
@ -622,6 +630,6 @@ note: required by a bound in `Diag::<'a, G>::arg`
= note: in this macro invocation
= note: this error originates in the macro `with_fn` (in Nightly builds, run with -Z macro-backtrace for more info)
error: aborting due to 79 previous errors
error: aborting due to 80 previous errors
For more information about this error, try `rustc --explain E0277`.