So it's been like a couple months since i wrote the last post about how i tackled an e-needs-test issue

This time, like i promised, i wanna go through something a bit more user-facing together with you - diagnostics

Note

A diagnostic is that nice, colored explanation the compiler shows you when something goes wrong. Instead of just shouting "error" and walking away, the compiler calmly points at what's off and usually nudges you toward a fix

For example

error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so it cannot be borrowed as mutable
  |
help: consider changing this to be a mutable reference
  |
7 | fn change(some_string: &mut String) {
  |                         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `f` (bin "f") due to 1 previous error

Beautiful, isn't it? Well, the thing is they're not as perfect as they look at first glance. If we open the issues we'll see a huge pile of issues on this topic, and even though the rust team puts a lot of effort into making them as user-friendly as possible (you know, those stories where you get 20kb of unreadable output from gcc), teachable and simple - i don't think it'll ever be possible to make them 100% perfect. But that doesn't mean we shouldn't improve them at all

Picking an Issue

So let's grab an issue and try to solve one

Fair warning

I'll say right away, the difficulty in this area ranges from "change one word" to "rewrite half of the borrow checker". But on average it's pretty gentle and somewhere in between, so solving them is interesting and fun (mostly)

Another fair warning

Bear in mind that your experience may differ significantly from mine, because diagnostics permeate the entire compiler, from the lexer right through to MIR; there's so much going on that it's impossible to take everything into account: parser recovery, name resolution, type checking, trait resolution, borrow checking; and that's just a small part of it ><

But you gotta pick the issue wisely, for the same reason as both warnings above

This one caught my eye

Shortly, right now for this code

fn take(_: impl Iterator<0>) {}

The compiler gives this error

error[E0107]: trait takes 0 generic arguments but 1 generic argument was supplied
  --> src/lib.rs:1:17
   |
 1 | fn take(_: impl Iterator<0>) {}
   |                 ^^^^^^^^ expected 0 generic arguments
   |
note: trait defined here, with 0 generic parameters
  --> /playground/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/iter/traits/iterator.rs:40:11
   |
40 | pub trait Iterator {
   |           ^^^^^^^^
help: replace the generic bound with the associated type
   |
 1 | fn take(_: impl Iterator<Item = 0>) {}
   |                          ++++++

Which is wrong and doesn't make any sense

And this code (it's a bit outdated)

Important

MGCA (min_generic_const_args) is a nightly feature that lets you use const values directly as generic arguments - Trait<0> instead of Trait<K = 0>. That's what this whole second example lives in

#![feature(min_generic_const_args)]
trait Trait { #[type_const] const K: i32; }
fn take(_: impl Trait<0>) {}

Spits out this error

error[E0107]: trait takes 0 generic arguments but 1 generic argument was supplied
 --> src/lib.rs:3:17
  |
3 | fn take(_: impl Trait<0>) {}
  |                 ^^^^^--- help: remove the unnecessary generics
  |                 |
  |                 expected 0 generic arguments
  |
note: trait defined here, with 0 generic parameters
 --> src/lib.rs:2:7
  |
2 | trait Trait { #[type_const] const K: i32; }
  |       ^^^^^

Even though it should suggest something like

impl Trait<K = 0>

Starting to Dig

First thing i make a clean branch

$ git checkout -b fix-mgca-assoc-sugg

And right away i kick off a compiler build

$ ./x b

So i don't have to do it later, plus I'll need to run the code above with the -Ztrack-diagnostics flag to find at least the rough place where the diagnostic gets created

While the compiler is building, one interesting detail about this issue - @fmease originally wanted to work on this problem but dropped his assignment

Ideally it's worth messaging him to ask what went wrong and why he decided not to fix it further - as one of the people who knows this area well, he'd give the right hints and help out

For newcomers

Reaching out to whoever was originally on an issue (or anyone who knows the area) is one of the best moves you can make. In the overwhelming majority of cases people respond super positively - maybe not right away, but positively

And so, 5 minutes later

Build completed successfully in 0:04:57

My compiler built (yeah I'm doing everything in real time, on one screen i have a text editor and on the other a code editor)

Let's run it on the file right away and see what's going on

gh-Kivooeo@dev-desktop-eu-1 ~/r/rust (fix-mgca-assoc-sugg) > ./build/aarch64-unknown-linux-gnu/stage1/bin/rustc -Ztrack-diagnostics /home/gh-Kivooeo/test_/src/main.rs --edition 2024
error[E0107]: trait takes 0 generic arguments but 1 generic argument was supplied
  --> /home/gh-Kivooeo/test_/src/main.rs:1:17
   |
 1 | fn take(_: impl Iterator<0>) {}
   |                 ^^^^^^^^ expected 0 generic arguments
   |
   = note: -Ztrack-diagnostics: created at compiler/rustc_hir_analysis/src/hir_ty_lowering/generics.rs:575:18
note: trait defined here, with 0 generic parameters
  --> library/core/src/iter/traits/iterator.rs:42:17
   |
42 | pub const trait Iterator {
   |                 ^^^^^^^^
help: replace the generic bound with the associated type
   |
 1 | fn take(_: impl Iterator<Item = 0>) {}
   |                          ++++++

error: aborting due to 1 previous error

Let's look into compiler/rustc_hir_analysis/src/hir_ty_lowering/generics.rs:575:18, hmm, at first glance this looks like exactly the function we need

/// Checks that the correct number of generic arguments have been provided.
/// This is used both for datatypes and function calls.
#[instrument(skip(cx, gen_pos), level = "debug")]
pub(crate) fn check_generic_arg_count(
    cx: &dyn HirTyLowerer<'_>,
    def_id: DefId,
    seg: &hir::PathSegment<'_>,
    gen_params: &ty::Generics,
    gen_pos: GenericArgPosition,
    has_self: bool,
) -> GenericArgCountResult

But looking at the logic, i don't see anything that could produce a suggestion like "add Item = 0"

Finding Where the Suggestion Is Born

So let's grep for the error text "replace the generic bound with the associated type". This grep found nothing except output in tests, so let's shorten it down to "replace the generic" and we immediately found what we need!

fn suggest_removing_args_or_generics(&self, err: &mut Diag<'_, impl EmissionGuarantee>)

And the specific check inside this function

if !self.is_in_trait_impl() {
    let unused_generics = &self.gen_args.args[self.num_expected_type_or_const_args()..];
    let suggestions = iter::zip(unused_generics, &unbound_types)
        .map(|(potential, name)| {
            (potential.span().shrink_to_lo(), format!("{name} = "))
        })
        .collect::<Vec<_>>();

    if !suggestions.is_empty() {
        err.multipart_suggestion(
            format!(
                "replace the generic bound{s} with the associated type{s}",
                s = pluralize!(unbound_types.len())
            ),
            suggestions,
            Applicability::MaybeIncorrect,
        );
    }
}

Here you can clearly see it by the suggestions variable. Now we gotta think where to move next

We have an interesting call chain through num_expected_type_or_const_args which is used here, but it can't really help us much

I didn't give you the full context above. Here's what the whole check looks like

        // If there is a single unbound associated type and a single excess generic param
        // suggest replacing the generic param with the associated type bound
        if provided_args_matches_unbound_traits && !unbound_types.is_empty() {
            // Don't suggest if we're in a trait impl as
            // that would result in invalid syntax (fixes #116464)
            if !self.is_in_trait_impl() {

Why did i do that? I didn't look at the outer checks right away myself, and the comments give pretty nice hints

Let's look at how unbound_types is defined

let unbound_types = self.get_unbound_associated_types();

Now let's look at this method

    fn get_unbound_associated_types(&self) -> Vec<String> {
        if self.tcx.is_trait(self.def_id) { // ok that's true
            let items: &AssocItems = self.tcx.associated_items(self.def_id); // get assoc item, nice
            items
                .in_definition_order() // fetch it in def. order
                .filter(|item| {   // filter by:
                    item.is_type() // only assoc types
                        && !item.is_impl_trait_in_trait() // not trait
                        && !self
                            .gen_args
                            .constraints
                            .iter()
                            .any(|constraint| constraint.ident.name == item.name())
                            //^ exclude associated types that are already bound in the generic args
                })
                .map(|item| self.tcx.item_ident(item.def_id).to_string()) // map it to string
                .collect() // and collect
        } else {
            Vec::default() // otherwise just empty vec
        }
    }

Anyway it's all clear. Surprisingly this code is pretty readable on its own, so let's make a simple change and see what happens?

- item.is_type()
+ (item.is_type() || item.is_type_const())

After that i run this interesting command

$ ./x b --keep-stage 1 && ./build/aarch64-unknown-linux-gnu/stage1/bin/rustc -Ztrack-diagnostics /home/gh-Kivooeo/test_/src/main.rs --edition 2024
Tip

Use --keep-stage 1 if you don't wanna recompile std and core, despite the flags name it works exactly like that

So... and for some reason it doesn't work

error: cannot find attribute `type_const` in this scope
 --> /home/gh-Kivooeo/test_/src/main.rs:2:17
  |
2 | trait Trait { #[type_const] const K: i32; }
  |                 ^^^^^^^^^^
  |
  = note: -Ztrack-diagnostics: created at compiler/rustc_resolve/src/macros.rs:1071:46

Ah right, i forgot to mention the code examples are a bit outdated. Now you have to write type const, so the example becomes that

And voila ✨

error[E0107]: trait takes 0 generic arguments but 1 generic argument was supplied
 --> /home/gh-Kivooeo/test_/src/main.rs:3:17
  |
3 | fn take(_: impl Trait<0>) {}
  |                 ^^^^^ expected 0 generic arguments
  |
  = note: -Ztrack-diagnostics: created at compiler/rustc_hir_analysis/src/hir_ty_lowering/generics.rs:575:18
note: trait defined here, with 0 generic parameters
 --> /home/gh-Kivooeo/test_/src/main.rs:2:7
  |
2 | trait Trait { type const K: i32; }
  |       ^^^^^
help: replace the generic bound with the associated type
  |
3 | fn take(_: impl Trait<K = 0>) {}
  |                       +++

The Actual Fix

Now the only thing left is to deal with the first case, where we get a really not-great diagnostic

For that i think we can go back to the zip logic, because it's the thing that adds this suggestion. We need to filter it somehow

Good news, this function is used only in this one specific place, so we can ruthlessly change its signature. I suggest this one

fn get_unbound_associated_types(&self) -> Vec<&AssocItem>

Not a string but the actual AssocItem, so we don't have to do (String, bool)

And we add a filter after the zip call, that checks the following - if we wanna suggest a const (like 0 for example) then we should check it's a type const, and if we wanna suggest a type then we should check it's a type

Literally just like this

    .filter(|(potential, item)| match potential {
        hir::GenericArg::Const(_) => item.is_type_const(),
        hir::GenericArg::Type(_) => item.is_type(),
        _ => false,
    })

Annd... yeah, it works!

error[E0107]: trait takes 0 generic arguments but 1 generic argument was supplied
  --> /home/gh-Kivooeo/test_/src/main.rs:1:17
   |
 1 | fn take(_: impl Iterator<0>) {}
   |                 ^^^^^^^^ expected 0 generic arguments
   |
   = note: -Ztrack-diagnostics: created at compiler/rustc_hir_analysis/src/hir_ty_lowering/generics.rs:575:18
note: trait defined here, with 0 generic parameters
  --> library/core/src/iter/traits/iterator.rs:42:17
   |
42 | pub const trait Iterator {
   |                 ^^^^^^^^

No more nonsense suggestion, just the error, exactly what we wanted

Running the Test Suite

Great, now let's run ./x test ui - I'm expecting a big number of regressions in our test suite

To my surprise only two tests. Let's look at their contents

First test: tests/ui/const-generics/issues/issue-87493.rs

Looks like this

pub trait MyTrait {
    type Assoc;
}

pub fn foo<S, T>(_s: S, _t: T)
where
    S: MyTrait,
    T: MyTrait<Assoc == S::Assoc>,
    //~^ ERROR: expected one of `,` or `>`, found `==`
    //~| ERROR: trait takes 0 generic arguments but 1 generic argument was supplied
{
}

fn main() {}

The original error was this

error[E0107]: trait takes 0 generic arguments but 1 generic argument was supplied
 --> src/main.rs:8:8
  |
8 |     T: MyTrait<Assoc == S::Assoc>,
  |        ^^^^^^^ expected 0 generic arguments
  |
note: trait defined here, with 0 generic parameters
 --> src/main.rs:1:11
  |
1 | pub trait MyTrait {
  |           ^^^^^^^
help: replace the generic bound with the associated type
  |
8 |     T: MyTrait<Assoc = Assoc == S::Assoc>,
  |                +++++++

You know, just for fun i decided to try writing what the compiler suggests here, and you know what i got? You'll never guess...

help: replace the generic bound with the associated type
  |
8 |     T: MyTrait<Assoc = Assoc = Assoc == S::Assoc>,
  |                +++++++

I tried it. The compiler responded with an even more broken version of the same suggestion - Assoc = Assoc = Assoc == S::Assoc. A beginner could sit there applying this forever... After our fix this problem got fixed and now it (kinda) correctly suggests the fix

error: expected one of `,` or `>`, found `==`
 --> /home/gh-Kivooeo/test_/src/main.rs:8:22
  |
8 |     T: MyTrait<Assoc == S::Assoc>,
  |                      ^^ expected one of `,` or `>`
  |
  = note: -Ztrack-diagnostics: created at compiler/rustc_parse/src/parser/diagnostics.rs:2528:34
help: if you meant to use an associated type binding, replace `==` with `=`
  |
8 -     T: MyTrait<Assoc == S::Assoc>,
8 +     T: MyTrait<Assoc = S::Assoc>,
  |

At least it compiles if you do what the compiler asks :)

Now let's look at the second test

//! This test used to ICE: #121134
//! The issue is that we're trying to prove a projection, but there's
//! no bound for the projection's trait, and the projection has the wrong
//! kind of generic parameter (lifetime vs type).
//! When actually calling the function with those broken bounds, trying to
//! instantiate the bounds with inference vars would ICE.
#![feature(unboxed_closures)]

trait Output<'a> {
    type Type;
}

struct Wrapper;

impl Wrapper {
    fn do_something_wrapper<O, F>(&mut self, _: F)
    where
        F: for<'a> FnOnce(<F as Output<i32>>::Type),
        //~^ ERROR: trait takes 0 generic arguments but 1 generic argument was supplied
    {
    }
}

fn main() {
    let mut wrapper = Wrapper;
    wrapper.do_something_wrapper(|value| ());
}

Anyway, some complicated code, but what's interesting, right now the compiler suggests the following - add Type =

help: replace the generic bound with the associated type
   |
18 |         F: for<'a> FnOnce(<F as Output<Type = i32>>::Type),
   |                                        ++++++

And after we add Type = i32, it suggests this

error[E0229]: associated item constraints are not allowed here
  --> src/main.rs:18:40
   |
18 |         F: for<'a> FnOnce(<F as Output<Type = i32>>::Type),
   |                                        ^^^^^^^^^^ associated item constraint not allowed here
   |
help: consider removing this associated item binding
   |
18 -         F: for<'a> FnOnce(<F as Output<Type = i32>>::Type),
18 +         F: for<'a> FnOnce(<F as Output<>>::Type),

What...? Yeah, this was kinda to the point about diagnostics not being perfect in the compiler. Anyway i ran around in circles for a pretty long time trying to do what the compiler suggests, and in the end it just didn't compile

Anyway, i think this isn't our problem anymore :)

The very fact that the compiler no longer suggests adding Type = here is already a win, and the other diagnostics fall out of scope of this fix

So with a calm soul we update the tests so they run with the new expected output. For the first one we just run with --bless

$ ./x test tests/ui/const-generics/issues/issue-87493.rs tests/ui/traits/generic_param_mismatch_in_unsatisfied_projection.rs --bless

And now we look

running 2 tests
..

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 21417 filtered out; finished in 51.58ms

Perfect!

Wrapping Up

Now, the last thing left to do is write a new test and add @fmease's suggestion to change the wording of this error

format!(
-    "replace the generic bound{s} with the associated type{s}",
+    "turn the generic argument{s} into an associated item binding{s}",
    s = pluralize!(unbound_types.len())
)

Now let's add a new test, if you don't know how to do that i suggest you read the previous post about adding tests!

Here's what our test will look like

//! Regression test for <https://github.com/rust-lang/rust/issues/151602>.

#![feature(min_generic_const_args)]

trait Trait {
    type const K: i32;
}
fn take(_: impl Trait<0>) {}
//~^ ERROR: trait takes 0 generic arguments but 1 generic argument was supplied [E0107]

fn main() {}

Since we changed the error wording, we'll need to run all the tests again and update the .stderr files for all of them

$ ./x test ui --bless

I think after that the only thing left is to open the pull request and assign @fmease to it (using r?)

What Happens Next?

While the tests are running, let's talk about this for a sec. I actually admit there might be some edge cases in this fix or the test, I'm not super familiar with the MGCA feature, even though i wrote the Ctor support and a couple other things for it. That was more thanks to the great mentorship and review from @BoxyUwU

Also a lot of useful info on this matter was said here

Well, it seems tests are compiled fine, except one test which requiers me to update the error:

fn trait_bound_generic<I: T<u8, u16>>(_i: I) {
    //~^ ERROR trait takes 0 generic arguments
-    //~| HELP replace the generic bounds with the associated types
+    //~| HELP turn the generic arguments into an associated item bindings
}

So it felt to me that fix is very ready for being published, let's run ./x test tidy to make very sure before opening a PR. Good, tidy seems fine about it

Build completed successfully in 0:00:41

Next step is to open a PR (done here), assign @fmease with r? and... well, that's basically it - now we wait :)

The Review Comes Back

So the wait wasn't that long actually, and @fmease left a few comments. And honestly this is the part i enjoy the most - when someone who knows the area way better than you looks at your code and points at the spots you were too eager to skim past

The first and the most important one was about my filter. Remember this part?

.filter(|(potential, item)| match potential {
    hir::GenericArg::Const(_) => item.is_type_const(),
    hir::GenericArg::Type(_) => item.is_type(),
    _ => false,
})

Looks fine right? Well, the thing is i was zipping unused_generics with unbound_types by position, and only then filtering by kind. But the assoc items come in definition order, and the generic args come in the order the user wrote them - there's no reason these two line up. @fmease gave a really clean example

trait Trait { type A; type const B: usize; }
fn f<T: Trait<(), 0>>(); // my PR emits a suggestion
trait Trait { type const A: usize; type B; }
fn f<T: Trait<(), 0>>(); // my PR does *not* emit one

In the second case the zip produces pairs like [(TyAssocConst, Ty), (AssocTy, Const)] and neither pair survives the filter, so we just silently drop a perfectly good suggestion. Oops

The fix is to stop pairing by position entirely. Instead i split the unbound items into two lazy iterators by kind, and let each generic arg pull the next item of its own kind

let mut unbound_assoc_consts = unbound_assoc_items.iter().filter(|item| item.is_type_const());
let mut unbound_assoc_types = unbound_assoc_items.iter().filter(|item| item.is_type());
let suggestions = unused_generics
    .iter()
    .filter_map(|potential| {
        let item = match potential {
            hir::GenericArg::Const(_) => unbound_assoc_consts.next(),
            hir::GenericArg::Type(_) => unbound_assoc_types.next(),
            _ => None,
        }?;
        Some((
            potential.span().shrink_to_lo(),
            // FIXME: This doesn't account for generic associated items
            format!("{} = ", self.tcx.item_ident(item.def_id)),
        ))
    })
    .collect::<Vec<_>>();

Each .next() advances its own cursor, so a type arg always grabs the next assoc type and a const the next assoc const, no matter how they're interleaved - and ? just bails (emitting nothing) if a queue runs dry instead of mispairing

The second comment was a tiny but nice one. The wording

"turn the generic argument{s} into an associated item binding{s}"

reads totally fine in singular, but the moment you have more than one it becomes "turn the generic arguments into an associated item bindings" which is, well, not english. So the an should only show up when there's exactly one. Quick fix

let s = pluralize!(suggestions.len());
let article = if suggestions.len() == 1 { "an " } else { "" };
err.multipart_suggestion(
    format!("turn the generic argument{s} into {article}associated item binding{s}"),
    suggestions,
    Applicability::MaybeIncorrect,
);

Also notice i switched the pluralization from unbound_types.len() to suggestions.len() - now that not every excess arg necessarily produces a binding, the count should reflect what we actually suggest

Then there was the comment above the whole block, which was honestly just a bit outdated. It still said

// If there is a single unbound associated type and a single excess generic param
// suggest replacing the generic param with the associated type bound

but the code handles multiple of them now, and assoc consts too, not only types. @fmease suggested generalizing it

// If there is an identical amount of unbound associated items and excess generic args
// suggest turning the generic args into associated item bindings.

with a funny little caveat that even "identical amount" isn't strictly true anymore after my kind-matching change (equal counts no longer guarantee a full set of bindings, since a type only pairs with a type and a const with a const). But it's still way better than the old wording, so in it goes

And the last one - @fmease asked me to drop a // FIXME: This doesn't account for generic associated items right above the format!("{} = ", …), since that's the bit that can't express a generic assoc item like type Assoc<T>; (we'd produce Assoc = with nowhere to put its generics). A separate (preexisting) bug, but a FIXME is the honest thing to leave behind

Tip

Don't be shy to leave FIXMEs. A FIXME that names a real limitation is much more useful than pretending the code is perfect - it's basically a little note to the next person (or future you)

Round Two of Nits

I pinged @rustbot ready and, like clockwork, a second smaller round came back. This one was the comfy kind - all naming and wording, nothing scary

The running theme was "make names tell the truth": a variable still called unbound_types was actually holding assoc consts too by now, so it got an honest rename, a one-off helper with a misleading name got inlined away, and a comment that said generic param where it meant generic arg got fixed (this corner of the compiler mixes those two up constantly)

Important

A big chunk of review comments are exactly this - a name, a word, a stale comment. It's not "you did it wrong", it's just how code stays readable for whoever touches it next, and it's the easiest kind of feedback to address

He also asked for one more test: that we don't suggest nonsense for a plain Iterator<0>. So i extended some test under tests/ui/suggestions/

pub trait T<X, Y> {
    type A;
    type B;
    type C;
}
pub struct Foo {
    i: Box<dyn T<usize, usize, usize, usize, B = usize>>,
    //~^ ERROR trait takes 2 generic arguments but 4 generic arguments were supplied
}

fn take(_: impl Iterator<0>) {} // <- here is what i've actually added !!!
//~^ ERROR trait takes 0 generic arguments but 1 generic argument was supplied

fn main() {}

The whole point is the .stderr has no "turn the generic argument" help - Iterator has an assoc type but no assoc const, so the const queue is empty and we suggest nothing, exactly as intended

After all that the suggestion finally does the right thing even in the weird ordering cases, the grammar is correct in both singular and plural, the names say what they mean, and the comment doesn't lie anymore

And now this is ready for merge 🎉 🎉 🎉

What I Hope You Take From This

If there's one thing i want you to leave with, it's that you really don't need to understand the whole compiler to make a diagnostic better. We took something that suggested complete nonsense, followed -Ztrack-diagnostics to the rough spot, found the exact code with a lazy grep, and made the suggestion only show up when it actually makes sense

A-diagnostics issues are great for this - a lot of them sit comfortably in the easy-to-medium range, they're user-facing so your work is immediately visible, and the people you'll meet along the way are genuinely kind

So if any of this looked fun - go pick one, grep around, ask questions, and don't be afraid to be wrong. That's literally how all of us started

And honestly, if you wanna try one but feel a little lost - you can just assign me as your reviewer with r? @Kivooeo (or ping me anywhere), i'm always happy to mentor newcomers on any kind of issues


On a side note, if you want to support future blog posts, my work on the compiler and mentoring newcomers, i'd be really grateful if you took a look at my sponsor page <3