Pre-RFC: The `#[diagnostic]` attribute namespace and the `#[diagnostic::on_unimplemented]` attribute

Following on the discussion around this compiler-MCP I've wrote a first draft of an RFC to provide a #[diagnostic::on_unimplemented] attribute for general usage. This attribute is supposed to allow crate authors to hint the compiler to emit specific error messages. Along with the specific attribute this RFC suggests to add a namespace with a set of common rules. This hopefully makes it easier to add similar attributes later on.


Summary

This RFC proposed to add a stable #[diagnostic] attribute namespace, which contains attributes to influence error messages emitted by the compiler. In addition it proposed to add a #[diagnostic::on_unimplemented] attribute to influence error messages emitted by unsatisfied traits bounds.

Motivation

Rust has the reputation to generate helpful error messages if something goes wrong. Nevertheless there are always cases of error messages which can be improved. One common example of such error messages in the rust ecosystem are those that are generated by crates using the type system to verify certain invariants at compile time. While these crates provide additional guarantees about invariants, they sometimes generate large error messages if something goes wrong. These error messages do not always indicate clearly what went wrong. Well known examples of crates with such issues include bevy, axum or diesel. Giving authors of such crates tools to control the error messages emited by the compiler would allow them to improve the situation on their own.

Guide-level explanation

This feature has two possible groups of users:

  • Users that develop code and consume error messages from the compiler
  • Users that write crates involving complex type hierarchies

The first user group will interact with the proposed feature through the error messages emitted by the compiler. As of this I do not expect any major documentation requirements for this group of users. Although we might want to indicate that a certain error message was provided via the described feature set, rather than by the compiler itself to prevent users for filling issues about bad error messages in the compilers issue tracker.

The second user group interacts with the described feature through attributes. These allow them to hint the compiler to emit specific error messages in certain cases. The #[diagnostic] attribute namespace provides a general framework for what can and can't be done by such an attribute. As of this users won't interact directly with the attribute namespace itself.

The #[diagnostic::on_unimplemented] attribute allows to hint the compiler to emit a specific error message if a certain trait is not implemented. This attribute should provide the following interface:

#[diagnostic::on_unimplemented(
    message="message",
    label="label",
    note="note"
)]
trait MyIterator<A> {
    fn next(&mut self) -> A;
}


fn iterate_chars<I: MyIterator<char>>(i: I) {
    // ...
}
fn main() {
    iterate_chars(&[1, 2, 3][..]);
}

which might result in the compiler emitting the following error message:

error[E0277]: message
  --> <anon>:14:5
   |
14 |     iterate_chars(&[1, 2, 3][..]);
   |     ^^^^^^^^^^^^^ label
   |
   = note: note
   = help: the trait `MyIterator<char>` is not implemented for `&[{integer}]`
   = note: required by `iterate_chars`

I expect the new attributes to be documented on the existing Diagnostics attributes page on the rust reference similar to existing attributes like for example #[deprecated]

Reference-level explanation

The #[diagnostic] attribute namespace

This RFC proposes to introduce a new #[diagnostic] attribute namespace. This namespace is supposed to contain different attributes, which allow users to hint the compiler to emit specific error messages in certain cases like type mismatches, unsatisfied trait bounds or similar situations. By collecting such attributes in a common namespace it is easier for users to find useful attributes and it is easier for the language team to establish a set of common rules for these attributes.

Any attribute in this namespace may:

  • Hint the compiler to emit a specific error message in a specific situation

Any attribute in this namespace is not allowed to:

  • Change the result of the compilation, which means applying such an attribute should never cause an error as long as they are syntactically valid

The compiler is allowed to:

  • Ignore the provided hints
  • Use only parts of the provided hints, for example for the proposed #[diagnostic::on_unimplemented] only use the message option
  • Change this behaviour at any time

The compiler must:

  • Verify that the attribute is syntactically correct, which means:
    • Its one of the accepted attributes
    • It only contains the allowed options
    • Any provided option follows the defined syntax
  • Follow the described semantics if the attribute is not ignored.

The #[diagnostic::on_unimplemented] attribute

This section describes the syntax of the on_unimplemented attribute and additionally how it is supposed to work. Implementing the later part is optional as outlined in the previous section.

#[diagnostic::on_unimplemented(
    message="message",
    label="label",
    note="note",
)]
trait MyIterator<A> {
    fn next(&mut self) -> A;
}

Each of the options message, label and note are optional. They are separated by comma. The tailing comma is optional. Specifying any of these options hints the compiler to replace the normally emitted part of the error message with the provided string. At least one of the options need to exist. The error message can include type information for the Self type or any generic type by using {Self} or {A} (where A refers to the generic type name in the definition).

#[diagnostic::on_unimplemented(
    on(
        _Self = std::string::String,
        note = "That's only emitted if Self == std::string::String",
    ),
    on(
        A = String,
        note = "That's only emitted if A == String",
    ),
    on(
        any(A = i32, _Self = i32),
        note = "That's emitted if A or Self is a i32",
    ),
    on(
        all(A = i32, _Self = i32),
        note = "That's emitted if A and Self is a i32",
    ),
    message="message",
    label="label",
    note="That's emitted if neither of the condition above are meet",
)]
trait MyIterator<A> {
    fn next(&mut self) -> A;
}

In addition the on() option allows to filter for specific cases. It accepts a set of filter options. A filter option consists on the generic parameter name from the trait definition and a type path against which the parameter should be checked. These type path could either be a fully qualified path or refer to any type in the current scope. As a special generic parameter name _Self is added to refer to the Self type of the trait implementation. A filter option evaluates to true if the corresponding generic paramater in the trait definition matches In addition it can contain one or more of the message, label or note option set to customise the error message hint in these cases. It is possible to supply more than one on() option per #[diagnostic::on_unimplemented] attribute. They are evaluated in the supplied order. The first matching option provides the corresponding value for the message/label/note options. If none of the on() option matches the values

The any and all option allows to combine multiple filter options. The any matches is one of the supplied filter options evaluates to true, the all option requires that all supplied filter options evaluate to true. These options can be nested to construct complex filters.

Drawbacks

A possible drawback is that this feature adds additional complexity to the compiler implementation. The compiler needs to handle an additional attribute namespace with at least one additional attribute.

Another drawback is that crates hint lower quality error messages than the compiler itself. Technically the compiler would be free to ignore such hints, practically I would assume that it is impossible to judge the quality of such error messages in an automated way.

Rationale and alternatives

This proposal tries to improve error messages generated by rustc. It would give crate authors a tool to influence what error message is emitted in a certain situation, as they might sometimes want to provide specific details on certain error conditions. Not implementing this proposal would result in the current status, where the compiler always shows a "general" error message, even if it would be helpful to show additional details.

Prior art

  • rustc_on_unimplemented already provides the described functionality as rustc internal attribute. It is used for improving error messages for various standard library API's. This repo contains several examples on how this attribute can be used in external crates to improve their error messages.
  • GHC provides an Haskell language extension for specifying custom compile time errors

Notably all of the listed similar features are univocal language extensions.

Unresolved questions

Clarify the procedure of various potential changes prior stabilisation of the attribute namespace:

  • Process of adding a new attribute to the #[diagnostics] attribute macro namespace
    • Requires a RFC?
    • Requires a language/compiler MCP? (My preferred variant)
    • Just a PR to the compiler?
  • Process of extending an existing attribute by adding more options
    • Requires a RFC?
    • Requires a language/compiler MCP?
    • Just a PR to the compiler?
  • Process of removing support for specific options or an entire attribute from rustc
    • Requires a compiler MCP?
    • Just a PR to the compiler?

Future possibilities

  • More attributes like #[diagnostics::on_type_error]
  • Extend the #[diagnostics::on_unimplemented] attribute to incorporate the semantics of #[do_not_recommend]
  • Un-RFC #[do_not_recommend]?
5 Likes

on_unimplemented is currently not very well organized and tend to grow to giant conditions.

I think it is need to be restructured. For example, I think it is better to put the on_unimplemented on the type and specify the trait instead of on clauses.

What if more than one of any given field exists? How about unrecognized options? (I love finding corner cases :slight_smile: )

1 Like

Given where you show label being printed, can you give an example where label would be useful? It seems like it would almost always want to be something like "for this function", with the real message in either message or note.

I would expect unstable additions to be a lang or lang/compiler MCP; for stable additions I'd expect an RFC to have occurred at some point. I'd expect removals to require a lang/compiler MCP.

I think this is the correct behavior. In particular, I think all three points are important and we should keep them.

1 Like

One quick remark for the on attribute: why use _Self = Type and not Self = Type?
Maybe this is something rustc's attribute parser cannot handle (e.g. because Self is treated as a full keyword), but this shouldn't be something the user has to care about.

1 Like

Thanks for pointing that out. Both cases would be an error, as the syntax is not correct. I should probably use some clearer wording there to indicate that only this options can appear once.

See this example from axum. It's sometimes helpful to point out concrete actions directly on the affected code. That written: I've included label mostly because that's already there in rustc_on_unimplemented and it corresponds clearly to a specific part of the error message. If that turns out to be problematic it's fine to just remove it. message is probably the most important option here.

I'm not sure if Self is accepted in that position. If that's valid I agree that this would be the preferred variant.

I do not see how that is supposed to minimize the amount of code required to generate a specific error message. After all you would need to have the same conditions somewhere. If you put the annotation on specific types you nevertheless would need to specify whether the type corresponds to Self or any generic parameter of the trait.

I didn't say this requires less code, but IMHO it is more readable when each type maintains its own error messages instead of a giant unrelated conditions for the trait.

If there are generic parameters.

In my experience the cases where custom error messages would be most useful tend to involve missing trait implementations causing a blanket impl to not apply. I think some kind of more complex filtering would be required to make that possible. As an example of the kind of bound I'm imagining: "_Self is a function that returns a future, but the returned future isn't Send"

It would be very reasonable to not open that can of worms for an initial feature, but it would probably be good to state that in the RFC. Something like that might also require the ability to introduce type parameters in filters which might need to be considered in the syntax of the initial feature.

As an alternative to one giant attribute, consider allowing and merging multiple attributes instead (same inorder matching rules), e.g.

#[diagnostic::on_unimplemented(
    if(Self = std::string::String),
    note = "That's only emitted if Self == std::string::String",
)]
#[diagnostic::on_unimplemented(
    if(A = String),
    note = "That's only emitted if A == String",
)]
#[diagnostic::on_unimplemented(
    if(any(A = i32, Self = i32)),
    note = "That's emitted if A or Self is a i32",
)]
#[diagnostic::on_unimplemented(
    if(all(A = i32, Self = i32)),
    note = "That's emitted if A and Self is a i32",
)]
#[diagnostic::on_unimplemented(
    message="message",
    label="label",
    note="That's emitted if neither of the condition above are meet",
)]
trait MyIterator<A> {
    fn next(&mut self) -> A;
}

A notable thing this can do which the OP RFC cannot is actually not provide any error message customization in the general case, only providing it for specific cases. It does mean duplicating some text (e.g. the message here) if it should be the same in all cases, but that might be fine tbh.

3 Likes

Thank you for pushing this forward.

Support for this was something I actually implemented for rustc_on_unimplemented, but it was deemed to be lang grammar change and it wasn't critical to have support for it because only people working on the stdlib would even be aware of this.

The big benefit of being able to annotate types with on_unimplemented or something similar is being able to customize errors for foreign traits, like extending std::fmt::Display if called on a type from my crate where you should have called .to_string_lossy() instead.

I'd agree, but I would love to focus on the namespacing question first (because it materially affects how the use of these attributes might feel like), and have separate conversations for the API of each potential attribute variation.

The current behavior is that you can use and() for constraining more, but I don't believe there's any sanity check that you haven't completely clobbered one of the customizations from a previous one. Effectively they are evaluated in order. This would be easy to add.

My original idea was to only warn on them, but the consensus seems to be to only allow what is explicitly supported. This should be ok.

If minimizing support to only customizing the main message with no filtering were to speed up putting this in the hands of developers, I would jump at that possibility, and give us time to bikeshed the wider API surface.

That's a good suggestion. That will also allow to easily put certain parts of the attribute behind a feature flag. I would expect that to be useful for cases where a certain type is only available behind a feature flag or just for cases where a certain impl is behind a feature flag. I will in cooperate that into the final RFC before opening a PR.

On the other hand you would loose the ability to emit always some error message for a specific trait. I think both use-cases might be important. Maybe add it to future work to allow the attribute in more places? Possibly on types?

That's a good suggestion. I will add that to the "Future possibilities" section.

To be clear: I think that we should land (a subset of?) the current behavior, but keep the options open for the alternative approach.

2 Likes

It occurs to me that the on conditions in these diagnostics are similar to negative impls — they're specifying a certain set of conditions in which we expect (though not actually require) a trait not to be implemented.

Thus when negative_impls is stable, it would be elegant to allow custom diagnostics to be attributes on a negative impl rather than the trait or the type — it will be the natural place to put such information because the negative impl is for the compiler and the diagnostic is saying the same thing in better words to the human.

#[diagnostic::on_unimplemented(
    note = "`.await` the future before using the `?` operator on its output"
)]
impl !Try for Future {}

This idle thought probably doesn't affect any of the design choices in this RFC, though, since it will still be useful to have on_unimplemented conditions that overlap with trait impls that do exist, so negative impls cannot be the only source of on_unimplemented messages even if they were stable.

2 Likes

I would like to thank you all for your input. This has been really helpful to shaping refining the proposed RFC. I've tried to incooperate most of the feedback in various parts of the RFC and posted a PR to the RFC repo here.

1 Like