Extending the #[diagnostic] tool attribute namespace

Recap of the stabilization of the #[diagnostic] namespace

This blog post aims to give an update on my recent work on Rust's #[diagnostic] tool attribute namespace. The namespace itself is the home for attributes that allow users to influence the diagnostic output given by the Rust compiler in case of a compilation error. The namespace and it's first attribute was stabilised with Rust 1.78 in May 2024. The first attribute, #[diagnostic::on_unimplemented], does allow to provide custom messages, labels and notes for the case that the marked trait is not implemented for a specific type. This is extremely useful for complex crates that model certain invariants as set of complex traits. In these cases its often possible to give a much better message to the user than the trait bound `{type}: {Trait}` is not satisfied. Since the stabilisation crates like axum, bevy, diesel or serde starting using the new attribute. For diesel I can confirm that this decreased the amount of support questions we get for specific errors. The Rust standard library replaced also replaced some usages of the internal permanently unstable #[rustc_on_unimplemented] with the new stable version. Overall I feel that the new attribute has contributed quite a bit to emit better compiler error messages in specific cases.

Adding a new #[diagnostic::do_not_recommend] attribute

With the stabilisation of the #[diagnostic] namespace, for me the question arose which feature I should work on next. At that point I looked back to my original Rust Foundation Project Grant proposal from 2022. This proposal mentioned the #[do_not_recommend] attribute, that was supposed to influence how the Rust compiler displays chains of complex trait resolutions in the case of that a specific bound is not fulfilled. This RFC was originally written with Diesel as motivating use case. I ended up not implementing this in 2022 as some members of the compiler team suggested back than that the trait resolution code in the compiler is not ready for that kind of adjustments yet.

The attribute aims to solve a problem with error messages where the Rust compiler tries to be too helpful and suggests that a certain other trait implementation is missing if a wild card implementation for that other trait exists. In some but not all cases, it would be helpful to show the actual trait bound instead. Consider the following simplified example case from Diesel:

// a typed SQL expression
pub trait Expression {
    type SqlType;
}

// a rust side expression that can be converted to a typed SQL expression
//
// This includes all types implementing `Expression` and some additional rust types
pub trait AsExpression<ST> {
    type Expression: Expression<SqlType = ST>;
    
    fn as_expression(self) -> Self::Expression;
}

// marker types for SQL side value types
pub struct Text;
pub struct Integer;

// a bind value of the rust side type `T`
pub struct Bound<T, ST>(T, std::marker::PhantomData<ST>); 

impl<T, ST> Expression for Bound<T, ST> {
    type SqlType = ST;
}

// a dummy SQL expression
pub struct SelectInt;

impl Expression for SelectInt {
    type SqlType = Integer;
}

// all rust types that represent SQL expressions 
// can be trivially converted to expressions
impl<T, ST> AsExpression<ST> for T
where 
   T: Expression<SqlType = ST> 
{
    type Expression = Self;
    
    fn as_expression(self) -> Self {
        self
    }

}

// A rust integer can be converted to a Bind value
impl AsExpression<Integer> for i32 {
    type Expression = Bound<i32, Integer>;
    
    fn as_expression(self) -> Bound<i32, Integer> {
        Bound(self, std::marker::PhantomData)
    }
}

// A rust string can be converted to a Bind value

impl AsExpression<Text> for String {
    type Expression = Bound<String, Text>;
    
    fn as_expression(self) -> Bound<String, Text> {
        Bound(self, std::marker::PhantomData)
    }
}

This setup allows Diesel to statically check that expressions of a certain SQL type can only be used in the correct places. It prevents for example that you accidental insert an Integer value into a Text column by emitting a compile time error instead.

Now Diesel provides some traits on top of this to construct more complex expressions. For example consider this exemplary implementation of an equal method that represents a a = b SQL expression. For that we want that both sides of the operator have the same SQL type.

pub struct Eq<A, B>(A, B);

trait ExpressionMethods: Expression + Sized {
    fn eq<T>(self, left: T) -> Eq<
        Self, 
        <T as AsExpression<<Self as Expression>::SqlType>>::Expression
    >
    where
        T: AsExpression<Self::SqlType>,
    {
        Eq(self, left.as_expression())
    }
}

impl<T> ExpressionMethods for T where T: Expression {}

This construct allows a user of that function to pass in either a type representing a SQL expression or a rust value as left side.

For example the following usages would be valid:

SelectInt.eq(SelectInt);
SelectInt.eq(42);

The following usages would be prevented by the compiler as the trait bounds do not match:

SelectInt.eq(String::from("whatever"));

You can checkout the complete example in this Rust playground

Unfortunately the compiler tries to be to helpful in this case and emits the following error message:

error[E0277]: the trait bound `String: Expression` is not satisfied
  --> src/main.rs:83:15
   |
83 |     SelectInt.eq(String::from("whatever"));
   |               ^^ the trait `Expression` is not implemented for `String`, which is required by `String: AsExpression<Integer>`
   |
   = help: the following other types implement trait `Expression`:
             Bound<T, ST>
             SelectInt
note: required for `String` to implement `AsExpression<Integer>`
  --> src/main.rs:36:13
   |
36 | impl<T, ST> AsExpression<ST> for T
   |             ^^^^^^^^^^^^^^^^     ^
37 | where 
38 |    T: Expression<SqlType = ST> 
   |       ------------------------ unsatisfied trait bound introduced here

For more information about this error, try `rustc --explain E0277`.

The compiler notices that the AsExpression trait bound would be fulfilled if the type implements Expression as there is a wild card implementation for that case. The problem with this suggestion is that it hides important information from the user. In particular the type of the ST generic argument of the AsExpression trait helps users to understand much easier what's wrong in this case. This information is still there as part of the label and as part of an additional note (required for `String` to implement `AsExpression<Integer>), but users tend to miss non-message parts of the error message. This particular issue is one of the more common problems that user encounter while using Diesel. If you are used to this error message it's relatively easy to solve as it just means that you tried to use a value of the wrong type in this particular location. Often it's either straight forward to convert the value to the correct type, e.g. by parsing the string as integer or it's a bug in the your implementation (e.g. using a string in this location is just not meaningful). The current error message makes it in particular for new Diesel users harder to understand what's wrong with their code.

RFC 2397 proposes to introduce a #[do_not_recommend] attribute, which can be placed on a trait implementation and which instructs the compiler to not consider this implementation while emitting error messages. This RFC was written in 2018, so long before the idea for the #[diagnostic] namespace even existed. It was not implemented in all this years. As far as I can tell one particular concern was always that the compiler implementation might change and it wouldn't be possible anymore to guarantee that this attribute is used as described by the RFC. It seems to be an ideal candidate for inclusion in the #[diagnostic] attribute namespace as that softens the guarantees the compiler must give. In particular it's allows to ignore the attribute for whatever reason, as it's merely a hint for the compiler if it's part of the #[diagnostic] namespace.

Given my previous experience with implementing the #[diagnostic] attribute namespace and the #[diagnostic::on_unimplemented] attribute, I decided to work on the implementation for this attribute as part of the #[diagnostic] namespace as well. If you are interested in the particular steps required to implement the new attribute, checkout the next section of the post. In the end it turned out easier than expected as soon as I figured out the right location for implementing the logic for skipping parts of the trait bound tree. This allows to improve the error message shown above to the following message:

error[E0277]: the trait bound `String: AsExpression<Integer>` is not satisfied
  --> src/main.rs:85:15
   |
85 |     SelectInt.eq(String::from("whatever"));
   |               ^^ the trait `AsExpression<Integer>` is not implemented for `String`
   |
   = help: the trait `AsExpression<Text>` is implemented for `String`
   = help: for that trait implementation, expected `Text`, found `Integer`

You can checkout the adjusted Rust Playground for the code and the new error message using a recent Rust nightly compiler.

In my opinion this is the much better message, as it directly mentions:

  • the AsExpression<Integer> bound
  • that there is a String: AsExpression<Text> implementation

I believe that this might help new users to understand the issue much easier.

As part of the implantation I asked for feedback and Alice Cecile, on of the bevy project maintainers, suggested another interesting case that might benefit from this attribute. Bevy does implement a lot of traits for tuples of different sizes. This often results in error messages that suggest a long list of generic tuple impls for a particular trait as candidates that implement this trait. Diesel and Axum have similar problems. An example for such an error message from Diesel looks like this:

error[E0277]: the trait bound `(diesel::sql_types::Integer, diesel::sql_types::Text): load_dsl::private::CompatibleType<User, _>` is not satisfied
  --> tests/fail/queryable_with_typemismatch.rs:21:31
   |
21 |     users::table.load::<User>(&mut conn).unwrap();
   |                  ----         ^^^^^^^^^ the trait `load_dsl::private::CompatibleType<User, _>` is not implemented for `(diesel::sql_types::Integer, diesel::sql_types::Text)`, which is required by `users::table: LoadQuery<'_, _, User>`
   |                  |
   |                  required by a bound introduced by this call
   |
   = note: this is a mismatch between what your query returns and what your type expects the query to return
   = note: the fields in your struct need to match the fields returned by your query in count, order and type
   = note: consider using `#[derive(Selectable)]` or #[derive(QueryableByName)] + `#[diesel(check_for_backend(_))]`
           on your struct `User` and in your query `.select(User::as_select())` to get a better error message
   = help: the following other types implement trait `load_dsl::private::CompatibleType<U, DB>`:
             (ST0, ST1)
             (ST0, ST1, ST2)
             (ST0, ST1, ST2, ST3)
             (ST0, ST1, ST2, ST3, ST4)
             (ST0, ST1, ST2, ST3, ST4, ST5)
             (ST0, ST1, ST2, ST3, ST4, ST5, ST6)
             (ST0, ST1, ST2, ST3, ST4, ST5, ST6, ST7)
             (ST0, ST1, ST2, ST3, ST4, ST5, ST6, ST7, ST8)
           and $N others
   = note: required for `users::table` to implement `LoadQuery<'_, _, User>`

In particular the help: the following other types implement trait `load_dsl::private::CompatibleType<U DB>`:part of the error message only lists generic tuple implementations for various sizes. These implementations are often not helpful for the user. Alice suggested that the new attribute might be used to hide trait implementations in this suggestion list as well. I went forward and implemented that proposal too. I feel that it can at least remove unnecessary visual clutter from some error messages as it is quite common to implement traits for tuples of various sizes. These impls are often not these that users need to be told about.

Finally I went forward and submitted a PR that uses the new attribute in the Rust Standard library to hide certain nightly only types in an error message that is often encountered by beginners.

The next step for this new attribute now would be stabilisation. For this I expect that I would need some more real world usage to show as part of the stabilisation PR. So if you are a crate author that would benefit from this feature I'm looking forward to hear about your success stories. Otherwise I hope that stabilising this feature as part of the existing #[diagnostic] namespace is now easier as that gives a general framework what such attributes are allowed to do and what guarantees the compiler gives about them.

Implementation of the new attribute

As part of this section of the blog post I want to show which steps were necessary to implement support for the new #[diagnostic::do_not_recommend] attribute. I also want to shortly show how I approach the problem and how I looked for the relevant locations in the rust compiler code base. I decided to write this section to the empower other people to work on general diagnostic improvements or even on their own extensions for the #[diagnostic] attribute namespace. At that point I also would like to thank Michael Goulet (@compiler-errors) for patiently reviewing my PR's and guiding me into the right direction while working on this feature. I also want to note that the following description skips over a few iterations and presents the end result of the work.

One of the most important things to write about contributing to the compiler is likely that it is just another large Rust code base, but it uses a slightly wired build system. What I want to say with that, is that if you are familiar with some more complex Rust project reading and navigating the compiler source code shouldn't be a large problem for you. The build system is slightly different to cargo due to the self hosting requirement, but in the end that just means you need to execute ./x.py test whatever instead of cargo test whatever. Details of how to get your system setup for building the compiler are described in the great Rustc Dev Guide.

My preferred way to developing new features is to write at least one test that allows me to verify the new behaviour later. In this particular case we need to handle two somewhat separate features:

  • The compiler needs to accept the new #[diagnostic::do_not_recommend] attribute
  • The compiler needs to do something with the attribute

The compiler test suite has a concept called compile tests. These kind of tests just take a source file and check the console output generated by compiler. This kind of tests is a great fit for working on compiler diagnostics. I added my new test following the example code in the blog post above in the /tests/ui/diagnostic_namespace/do_not_recommend folder. My next step was to just run this test without the new attribute to verify that the test is picked up. That run failed as expected as we did not have a console output to compare the result to. The --bless option allows us to easily generate that output.

Now we can look back at the points mentioned before. We need to start with telling the compiler to accept the new attribute. For this I first added the attribute to my test case. Now running the test again resulted, as expected, in a test failure as the compiler warned about an unknown #[diagnostic] attribute. As the compiler code base is really huge, I (and you) are not expected to know where everything is. I use two different ways to figure out what locations might be relevant for the task at hand:

  • Searching for (a part of) the string from the error message
  • Searching for the location where a similar thing is done (In this case registering the #[diagnostic::on_unimplemented] attribute)

Both ways lead you to the following location. For the first step, finding that location was the hardest part, as adjusting the code is relatively straight forward from there. I just needed to mirror what was already done for #[diagnostic::on_unimplemented] attribute. That change makes the compiler to accept the new attribute. A quick search on the #[diagnostic::on_unimplemented] attribute also shows that the compiler checks whether the attribute is valid in a specific location, so I also added support for #[diagnostic::do_not_recommend] there. With these changes the compiler accepted the new attribute in my test.

The next and more important step is to implement the actual functionality, so modifying the emitted error message in this case. Again I needed to find the right location for that. I started with searching where the current error message is emitted. That lead to this location. I then started to look through that module and read some of the code. I focused on finding the location where the note: required for `String` to implement `AsExpression<Integer> and the label part which is required by `String: AsExpression<Integer> are generated. These message parts already contain the trait I want to get in the main message, so the code around there should show how I can access the relevant information. This search lead me to the this and that location. Both locations indicate that the obligation variable with the type PredicateObligation contain the relevant information. At that point I inserted a few dbg! calls to dump the content of that variable (and a few others) to understand the general structure of the reporting. Executing my test again gave me the relevant output. With this output it became clear that PredicateObligation represents some sort of tree structure which contains all trait bounds the compiler knows about for this particular case. For implementing #[diagnostic::do_not_recommend] as specified I "just" needed to walk up the tree until I do not encounter an item that is marked with #[diagnostic::do_not_recommend] anymore. Implementing that is actually not that complicated. It took a few tries and some repeated usage of the dbg! macro to figure out the right point point to actually call this function. With that change my test case started to emit the desired error message.

At that point the work on the new attribute was mostly finished, so I opened a new PR to the main Rust repository. As often during code review there are a few things found that could be improved. This usually results in a some steps back and forth between the reviewer and contributor. You can see the complete change in the relevant PR

I hope that this description of how I approach contributing to the compiler empowers others to start contributing there as well. The main takeaway for you should be:

  • The compiler is written in Rust, so it's "just" Rust code that you can read and modify as in any other project
  • You don't need to know everything about the compiler to contribute. You can go really far by some targeted search + generous use of debug printing
  • There are often people that can help you if you ask questions
  • Improving compiler diagnostics is a good way to bring changes to the compiler that impact a lot of users and that are relatively straightforward to implement and test

As always you can support my work by sponsoring me on GitHub.