-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Add direct-recursion Lint #15006
New issue
Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? # to your account
base: master
Are you sure you want to change the base?
Add direct-recursion Lint #15006
Conversation
@PLeVasseur WIP. I think the implementation is correct. I'll complete the documentation later, hopefully this weekend ^^ |
Nice! Thanks for taking on the implementation. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey @felix91gr -- thanks for writing this up.
One thing I was unclear on: should this lint also check for recursion that's due to macros or only functions?
Well... since it's a |
d955bfc
to
e06510b
Compare
Sorry for the CI churn. It took me a while to understand why it was failing the |
e06510b
to
30beb95
Compare
rustbot has assigned @samueltardieu. Use |
This comment has been minimized.
This comment has been minimized.
30beb95
to
1f8f785
Compare
I feel like this should be in the quick CI checks (like the |
Something that I'd like to add in a later PR, is a link to the Safety Critical Coding Guidelines where recursion is addressed. Currently the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice documentation addition, @felix91gr!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't flag all potentially direct recursive calls. For example, it doesn't trigger on this code path:
fn recurse_through_var(x: i32) -> i32 {
let call = if x < 10 {
std::convert::identity
} else {
recurse_through_var
};
call(x - 1)
}
Should I add this caveat to the documentation? Context: That kind of indirect call through a variable is ultimately undecidable (as a consequence of constprop being undecidable). That being said, one can attempt to detect as many of those cases as one can (as most lints that touch on undecidability do). But still, this kind of detection requires MIR machinery that we don't have (in particular, it requires dataflow analysis capable of doing non-lexical constant propagation). I might implement it with the help of some friends in the future, but for now doing this properly in Clippy would require me to implement it in Clippy... which is outside of the scope of this lint. See also: the transmute-null-ptr-to-ref lint. When I implemented it, I stopped at the base cases due to the same lack of dataflow machinery. |
@samueltardieu does that make sense? I hope that makes sense. Basically, I don't want to implement variable-bound-recursion-detection without the necessary dataflow machinery; it's going to be a pain and it's going to be a lot less complete than it would be otherwise. It should probably be noted somewhere in the lint docs that this implementation only touches direct fn call expressions. I could open up an issue as well noting down the code paths that a more sophisticated recursion analysis could catch, and write down exactly what such a lint is currently blocked on. |
Edit: sorry, I had a brain fart and thought I'd found a false negative for the lint.
Draft mode again. I'm not linting this case:
enum Tree {
Leaf(i32),
Node(Box<Tree>, Box<Tree>),
}
fn sum_tree(tree: &Tree) -> i32 {
match tree {
Tree::Leaf(val) => *val,
Tree::Node(left, right) => sum_tree(left) + sum_tree(right),
}
} |
Drat. I'm an idiot. I AM linting this case. It's just that I have two very similar working directories, one of them for indirect recursion, and that one hasn't been updated to match method calls... 🤦 |
Okay, this I think is an actual counterexample trait RecSum {
fn rec_sum(n: u32) -> u32;
}
struct Summer;
impl RecSum for Summer {
fn rec_sum(n: u32) -> u32 {
if n == 0 { 0 } else { n + Self::rec_sum(n - 1) }
}
} |
09c21cd
to
c7af34b
Compare
Solved the counterexample, and ditched the custom I think.... this is done now? We'll see. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had a quick initial review there.
// This is a test of such a feature. | ||
fn i_call_myself_in_a_bounded_way(bound: u8) { | ||
if bound > 0 { | ||
#[clippy::allowed_recursion] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand the need for a dedicated attribute. If you are allowed to use it on an expression, you can allow the lint. If you want to allow it at a function level, then every inner recursive call, by construction, will inherit this allow, and won't be linted.
if self.is_blocked() { | ||
return; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand why it is necessary, or useful. It seems to only complicate the logic. What are you trying to prevent exactly here? Isn't that overengineering?
clippy_lints/src/direct_recursion.rs
Outdated
// Uniquely identifying the `DefId` of method calls requires doing type checking. | ||
// `cx` takes care of that, and then we can ask for the `type_dependent_def_id` | ||
// of the `expr` whose kind is `ExprKind::MethodCall`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You don't need to explain every step of what the lint is doing here, type_dependent_def_id()
is used everywhere throughout Clippy (100+ times), particularly on method calls, it is known what it is doing.
clippy_lints/src/direct_recursion.rs
Outdated
// This should almost always be true, as far as I'm aware. | ||
// `ExprKind::Call` values are supposed to contain an `Expr` of type `ExprKind::Path` | ||
// inside of them. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do you think that? Couldn't that be a field access, a function call, or any expression that returns something implementing FnOnce
? This comment should probably be removed.
Also, this is not linted while it should: struct T(u32);
trait W {
fn f(&self);
}
impl W for T {
fn f(&self) {
if self.0 > 0 {
self.f()
}
}
} |
Also you should store |
You can probably remove all the matching against (this will also remove any duplicate for free since you won't match for calls) |
You can look at the |
clippy_lints/src/direct_recursion.rs
Outdated
match fn_qpath { | ||
QPath::Resolved(_, path) => { | ||
if let Res::Def(def_kind, def_id) = path.res { | ||
for stored_fn_id in &self.fn_id_stack { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seeing that this walks through multiple bodies, I think there would be a false positive if you have a nested const/static call the enclosing function. Something like this
const fn add(a: i32, b: i32) -> i32 {
const _: () = assert!(add(1, 2) == 3); // no recursion here
a + b
}
I was a bit surprised initially about the fact that this (intentionally) lints nested functions calling an enclosing function, even if that doesn't actually create recursion (the lint description specifically mentions only linting calls to itself).
But actually thinking about it more, I guess people who'd enable this type of lint would rather have more FPs than FNs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Still though, linting nested fns calling a parent fn seems a bit inconsistent if there's no recursion going on at all, like in
fn a() -> fn() {
fn b() { a(); }
b
}
a()();
I wonder if this is something that would be better handled by the hypothetical indirect_recursion
? I don't know, it's hard to tell without knowing how exactly that would work. But that also makes me wonder what the actual use case for this limited version of a lint be, which would have a bunch of false positives and also false negatives
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All those are edge cases, unlikely to happen in a safety critical context for example, where you would want to forbid recursion through this restriction lint. This is why I am not worried about those kind of false positives, while I would really dislike having any false negative, as any kind of false negative would make the lint useless in the context of some certification.
Ah, you're right. In prior iterations, I was doing the conversion the other way around so I was fine with keeping a stack of
Yeah, no, you're absolutely correct. |
That makes sense. I continued this topic in the Zulip because it has been brought up multiple times, and I feel like the Zulip is a better place to have a conversation about that. |
c7af34b
to
d6e4917
Compare
I'm still solving this failing test: struct T(u32);
trait W {
fn f(&self);
}
impl W for T {
fn f(&self) {
if self.0 > 0 {
self.f()
}
}
} |
changelog: new lint: [`direct_recursion`]
d6e4917
to
7f59b00
Compare
I fixed the case above, but I'm not entirely sure why I need both of these branches to capture default Trait implementations v/s non-default implementations: ExprKind::MethodCall(_, _, _, _) => {
let typeck = cx.typeck_results();
if let Some(basic_id) = typeck.type_dependent_def_id(expr.hir_id) {
// This finds default Trait implementations of methods
if self.fn_id_stack.contains(&basic_id) {
span_lint(cx, DIRECT_RECURSION, expr.span, "this method contains a call to itself");
}
// Whereas this finds non-default implementations
else if let args = typeck.node_args(expr.hir_id)
&& let Ok(Some(fn_def)) = Instance::try_resolve(cx.tcx, cx.typing_env(), basic_id, args)
&& let type_resolved_def_id = fn_def.def_id()
&& self.fn_id_stack.contains(&type_resolved_def_id)
{
span_lint(cx, DIRECT_RECURSION, expr.span, "this method contains a call to itself");
}
}
}, Like, it would make a lot of sense to me if I had to always follow the second branch. But for some reason, this test doesn't pass without the first one: // Case 8: Linting on Default Trait Method Implementations //
// Here we check that recursion in trait methods is also captured by the lint
trait MyTrait {
fn myfun(&self, num: i32) {
if num > 0 {
self.myfun(num - 1);
//~^ direct_recursion
}
}
} |
At the |
Checklist:
.stderr
file)cargo test
passes locallycargo dev update_lints
cargo dev fmt
changelog: new lint: [
direct_recursion
]Fixes #428
After we're done with this, I'll open a follow-up issue for the indirect recursion lint