The direct cause is simply because it seems Rust attributes the jump to the if, note the lack of any associated instructions with the break line in this godbolt example:
In there the lines are assigned as:
; pub fn main() {
push rax
; loop {
jmp .LBB0_1
; before();
.LBB1_1:
call example[ceccecd8beb92274]::before
; if maybe() {
call example[ceccecd8beb92274]::maybe
test al, 1
jne .LBB0_3
; break;
; }
; after();
call example[ceccecd8beb92274]::after
jmp .LBB0_1
; }
;}
.LBB0_3:
pop rax
ret
Without any actual instruction to stop the processor on, you can't put a breakpoint or step on to the break.
But that only begs the question, why doesn't it assign the jne to the break?
This gets into the Rust compiler internals, unfortunately. The MIR (middle-intermediate representation for main here is (bb here means "basic block" - a sequence of instructions without any interior control flow):
fn main() -> () {
let mut _0: ();
let _1: ();
let mut _2: bool;
let _3: ();
bb0: {
goto -> bb1;
}
bb1: {
_1 = before() -> [return: bb2, unwind continue];
}
bb2: {
_2 = maybe() -> [return: bb3, unwind continue];
}
bb3: {
switchInt(move _2) -> [0: bb5, otherwise: bb4];
}
bb4: {
return;
}
bb5: {
_3 = after() -> [return: bb1, unwind continue];
}
}
This is roughly:
fn main() {
bb1:
before();
goto bb2;
bb2:
let _2 = maybe();
goto bb3;
bb3:
match _2 {
false => goto bb5,
_ => goto bb4,
}
bb4:
return;
bb5:
after();
goto bb1;
}
In other words, it's already "de-sugured" the if into a match and jump directly to the end of the function by the time it's done processing the surface syntax and starts considering the actual behavior - which makes sense because it doesn't actually matter which of these you write:
loop {
if maybe() {
foo();
break;
}
bar()
}
// vs
loop {
if !maybe() {
bar();
continue;
}
foo();
break;
}