Skip to content

Commit b5548ba

Browse files
feat(linter): Implement immediately_bind_scoped rule (#862)
1 parent 5a4418b commit b5548ba

File tree

18 files changed

+495
-59
lines changed

18 files changed

+495
-59
lines changed

nova_lint/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ path = "ui/gc_scope_comes_last.rs"
2828
name = "gc_scope_is_only_passed_by_value"
2929
path = "ui/gc_scope_is_only_passed_by_value.rs"
3030

31+
[[example]]
32+
name = "immediately_bind_scoped"
33+
path = "ui/immediately_bind_scoped.rs"
34+
3135
[[example]]
3236
name = "no_it_performs_the_following"
3337
path = "ui/no_it_performs_the_following.rs"

nova_lint/src/agent_comes_first.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use clippy_utils::{diagnostics::span_lint_and_help, is_self};
2-
use rustc_hir::{def_id::LocalDefId, intravisit::FnKind, Body, FnDecl};
2+
use rustc_hir::{Body, FnDecl, def_id::LocalDefId, intravisit::FnKind};
33
use rustc_lint::{LateContext, LateLintPass};
44
use rustc_span::Span;
55

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
use std::{fmt::Display, ops::ControlFlow};
2+
3+
use crate::{is_scoped_ty, method_call};
4+
use clippy_utils::{
5+
diagnostics::span_lint_and_help,
6+
get_enclosing_block, get_parent_expr,
7+
paths::{PathNS, lookup_path_str},
8+
res::MaybeResPath,
9+
ty::implements_trait,
10+
visitors::for_each_expr,
11+
};
12+
13+
use rustc_hir::{Expr, ExprKind, HirId, Node, PatKind, StmtKind};
14+
use rustc_lint::{LateContext, LateLintPass};
15+
use rustc_middle::ty::Ty;
16+
17+
dylint_linting::declare_late_lint! {
18+
/// ### What it does
19+
///
20+
/// Makes sure that the user immediately binds `Scoped<Value>::get` and
21+
/// `Scoped<Value>::take` results.
22+
///
23+
/// ### Why is this bad?
24+
///
25+
/// To avoid odd bugs with the garbage collector when dealing with scoped
26+
/// values, it is important that `Scoped<Value>::get` and
27+
/// `Scoped<Value>::take` results are immediately bound.
28+
///
29+
/// ### Example
30+
///
31+
/// ```
32+
/// let a = scoped_a.get(agent);
33+
/// ```
34+
///
35+
/// Use instead:
36+
///
37+
/// ```
38+
/// let a = scoped_a.get(agent).bind(gc.nogc());
39+
/// ```
40+
///
41+
/// Which ensures that no odd bugs occur.
42+
///
43+
/// ### Exception: If the result is immediately used without assigning to a
44+
/// variable, binding can be skipped.
45+
///
46+
/// ```
47+
/// scoped_a.get(agent).internal_delete(agent, scoped_b.get(agent), gc.reborrow());
48+
/// ```
49+
///
50+
/// Here it is perfectly okay to skip the binding for both `scoped_a` and
51+
/// `scoped_b` as the borrow checker would force you to again unbind both
52+
/// `Value`s immediately.
53+
pub IMMEDIATELY_BIND_SCOPED,
54+
Deny,
55+
"the result of `Scoped<Value>::get` or `Scoped<Value>::take` should be immediately bound"
56+
}
57+
58+
impl<'tcx> LateLintPass<'tcx> for ImmediatelyBindScoped {
59+
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
60+
// First we check if we have found a `Scoped<Value>::get` or `Scoped<Value>::take` call
61+
if let Some(scoped_method) = is_scoped_get_or_take_method_call(cx, expr) {
62+
// Which is followed by a trait method call to `bind` in which case
63+
// it is all done properly and we can exit out of the lint.
64+
if let Some(parent) = get_parent_expr(cx, expr)
65+
&& is_bindable_bind_method_call(cx, parent)
66+
{
67+
return;
68+
}
69+
70+
// Check if the unbound value is used in an argument position of a
71+
// method or function call where binding can be safely skipped.
72+
if is_in_argument_position(cx, expr) {
73+
return;
74+
}
75+
76+
// If the expression is assigned to a local variable, we need to
77+
// check that it's next use is binding or as a function argument.
78+
if let Some(local_hir_id) = get_assigned_local(cx, expr)
79+
&& let Some(enclosing_block) = get_enclosing_block(cx, expr.hir_id)
80+
{
81+
let mut found_valid_next_use = false;
82+
83+
// Look for the next use of this local after the current expression.
84+
// We need to traverse the statements in the block to find proper usage
85+
for stmt in enclosing_block
86+
.stmts
87+
.iter()
88+
.skip_while(|s| s.span.lo() < expr.span.hi())
89+
{
90+
// Extract relevant expressions from the statement and check
91+
// it for a use valid of the local variable.
92+
let Some(stmt_expr) = (match &stmt.kind {
93+
StmtKind::Expr(expr) | StmtKind::Semi(expr) => Some(*expr),
94+
StmtKind::Let(local) => local.init,
95+
_ => None,
96+
}) else {
97+
continue;
98+
};
99+
100+
// Check each expression in the current statement for use
101+
// of the value, breaking when found and optionally marking
102+
// it as valid.
103+
if for_each_expr(cx, stmt_expr, |expr_in_stmt| {
104+
if expr_in_stmt.res_local_id() == Some(local_hir_id) {
105+
if is_valid_use_of_unbound_value(cx, expr_in_stmt, local_hir_id) {
106+
found_valid_next_use = true;
107+
}
108+
109+
return ControlFlow::Break(true);
110+
}
111+
ControlFlow::Continue(())
112+
})
113+
.unwrap_or(false)
114+
{
115+
break;
116+
}
117+
}
118+
119+
if !found_valid_next_use {
120+
span_lint_and_help(
121+
cx,
122+
IMMEDIATELY_BIND_SCOPED,
123+
expr.span,
124+
format!(
125+
"the result of `Scoped<Value>::{}` should be immediately bound",
126+
scoped_method
127+
),
128+
None,
129+
"immediately bind the value",
130+
);
131+
}
132+
}
133+
}
134+
}
135+
}
136+
137+
/// Check if an expression is assigned to a local variable and return the local's HirId
138+
fn get_assigned_local(cx: &LateContext<'_>, expr: &Expr) -> Option<HirId> {
139+
let parent_node = cx.tcx.parent_hir_id(expr.hir_id);
140+
141+
if let Node::LetStmt(local) = cx.tcx.hir_node(parent_node)
142+
&& let Some(init) = local.init
143+
&& init.hir_id == expr.hir_id
144+
&& let PatKind::Binding(_, hir_id, _, _) = local.pat.kind
145+
{
146+
Some(hir_id)
147+
} else {
148+
None
149+
}
150+
}
151+
152+
/// Check if a use of an unbound value is valid, this is either by being a
153+
/// binding or function argument, or in the return position of a function.
154+
fn is_valid_use_of_unbound_value(cx: &LateContext<'_>, expr: &Expr, hir_id: HirId) -> bool {
155+
// Check if we're in a method call and if so, check if it's a bind call
156+
if let Some(parent) = get_parent_expr(cx, expr)
157+
&& is_bindable_bind_method_call(cx, parent)
158+
{
159+
return true;
160+
}
161+
162+
// If this is a method call to bind() on our local, it's valid
163+
if is_bindable_bind_method_call(cx, expr) {
164+
return true;
165+
}
166+
167+
// If this is the local being used as a function argument, it's valid
168+
if expr.res_local_id() == Some(hir_id) && is_in_argument_position(cx, expr) {
169+
return true;
170+
}
171+
172+
// If this is the self value of a method call, it's valid
173+
if expr.res_local_id() == Some(hir_id) && is_in_self_position(cx, expr) {
174+
return true;
175+
}
176+
177+
false
178+
}
179+
180+
fn is_in_self_position(cx: &LateContext<'_>, expr: &Expr) -> bool {
181+
let mut current_expr = expr;
182+
183+
// Walk up the parent chain to see if we're in a method call
184+
while let Some(parent) = get_parent_expr(cx, current_expr) {
185+
// If we find a method call where our expression is in the receiver position
186+
if let ExprKind::MethodCall(_, receiver, _, _) = parent.kind
187+
&& receiver.hir_id == current_expr.hir_id
188+
{
189+
return true;
190+
}
191+
// Else continue walking up for other expression types
192+
current_expr = parent;
193+
}
194+
195+
false
196+
}
197+
198+
/// Check if an expression is in an argument position where binding can be skipped
199+
fn is_in_argument_position(cx: &LateContext<'_>, expr: &Expr) -> bool {
200+
let mut current_expr = expr;
201+
202+
// Walk up the parent chain to see if we're in a function call argument
203+
while let Some(parent) = get_parent_expr(cx, current_expr) {
204+
match parent.kind {
205+
// If we find a method call where our expression is an argument (not receiver)
206+
ExprKind::MethodCall(_, receiver, args, _) => {
207+
if receiver.hir_id != current_expr.hir_id
208+
&& args.iter().any(|arg| arg.hir_id == current_expr.hir_id)
209+
{
210+
return true;
211+
}
212+
}
213+
// If we find a function call where our expression is an argument
214+
ExprKind::Call(_, args) => {
215+
if args.iter().any(|arg| arg.hir_id == current_expr.hir_id) {
216+
return true;
217+
}
218+
}
219+
// Continue walking up for other expression types
220+
_ => {}
221+
}
222+
current_expr = parent;
223+
}
224+
225+
false
226+
}
227+
228+
enum ScopedMethod {
229+
Get,
230+
Take,
231+
}
232+
233+
impl Display for ScopedMethod {
234+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235+
match self {
236+
ScopedMethod::Get => write!(f, "get"),
237+
ScopedMethod::Take => write!(f, "take"),
238+
}
239+
}
240+
}
241+
242+
fn is_scoped_get_or_take_method_call(cx: &LateContext<'_>, expr: &Expr) -> Option<ScopedMethod> {
243+
if let Some((method, recv, _, _, _)) = method_call(expr)
244+
&& let typeck_results = cx.typeck_results()
245+
&& let recv_ty = typeck_results.expr_ty(recv)
246+
&& is_scoped_ty(cx, &recv_ty)
247+
{
248+
match method {
249+
"get" => Some(ScopedMethod::Get),
250+
"take" => Some(ScopedMethod::Take),
251+
_ => None,
252+
}
253+
} else {
254+
None
255+
}
256+
}
257+
258+
fn is_bindable_bind_method_call(cx: &LateContext<'_>, expr: &Expr) -> bool {
259+
if let Some((method, _, _, _, _)) = method_call(expr)
260+
&& method == "bind"
261+
&& let expr_ty = cx.typeck_results().expr_ty(expr)
262+
&& implements_bindable_trait(cx, &expr_ty)
263+
{
264+
true
265+
} else {
266+
false
267+
}
268+
}
269+
270+
fn implements_bindable_trait<'tcx>(cx: &LateContext<'tcx>, ty: &Ty<'tcx>) -> bool {
271+
lookup_path_str(cx.tcx, PathNS::Type, "nova_vm::engine::Bindable")
272+
.first()
273+
.is_some_and(|&trait_def_id| implements_trait(cx, *ty, trait_def_id, &[]))
274+
}

nova_lint/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ mod agent_comes_first;
2828
mod can_use_no_gc_scope;
2929
mod gc_scope_comes_last;
3030
mod gc_scope_is_only_passed_by_value;
31+
mod immediately_bind_scoped;
3132
mod no_it_performs_the_following;
3233
mod no_multipage_spec;
3334
mod spec_header_level;
@@ -41,6 +42,7 @@ pub fn register_lints(sess: &rustc_session::Session, lint_store: &mut rustc_lint
4142
can_use_no_gc_scope::register_lints(sess, lint_store);
4243
gc_scope_comes_last::register_lints(sess, lint_store);
4344
gc_scope_is_only_passed_by_value::register_lints(sess, lint_store);
45+
immediately_bind_scoped::register_lints(sess, lint_store);
4446
no_it_performs_the_following::register_lints(sess, lint_store);
4547
no_multipage_spec::register_lints(sess, lint_store);
4648
spec_header_level::register_lints(sess, lint_store);

nova_lint/src/utils.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use clippy_utils::peel_hir_ty_options;
2-
use rustc_hir::{FnSig, HirId, ItemKind, Node, def_id::DefId, intravisit::FnKind};
2+
use rustc_hir::{Expr, ExprKind, FnSig, HirId, ItemKind, Node, def_id::DefId, intravisit::FnKind};
33
use rustc_hir_analysis::lower_ty;
44
use rustc_lint::LateContext;
55
use rustc_middle::ty::{Ty, TyKind};
6-
use rustc_span::symbol::Symbol;
6+
use rustc_span::{Span, symbol::Symbol};
77

88
// Copyright (c) 2014-2025 The Rust Project Developers
99
//
@@ -21,6 +21,25 @@ pub fn match_def_path(cx: &LateContext<'_>, did: DefId, syms: &[&str]) -> bool {
2121
.eq(path.iter().copied())
2222
}
2323

24+
// Copyright (c) 2014-2025 The Rust Project Developers
25+
//
26+
// Originally copied from `dylint` which in turn copied it from `clippy_lints`:
27+
// - https://github.com/trailofbits/dylint/blob/d1be1c42f363ca11f8ebce0ff0797ecbbcc3680b/examples/restriction/collapsible_unwrap/src/lib.rs#L180
28+
// - https://github.com/rust-lang/rust-clippy/blob/3f015a363020d3811e1f028c9ce4b0705c728289/clippy_lints/src/methods/mod.rs#L3293-L3304
29+
/// Extracts a method call name, args, and `Span` of the method name.
30+
pub fn method_call<'tcx>(
31+
recv: &'tcx Expr<'tcx>,
32+
) -> Option<(&'tcx str, &'tcx Expr<'tcx>, &'tcx [Expr<'tcx>], Span, Span)> {
33+
if let ExprKind::MethodCall(path, receiver, args, call_span) = recv.kind
34+
&& !args.iter().any(|e| e.span.from_expansion())
35+
&& !receiver.span.from_expansion()
36+
{
37+
let name = path.ident.name.as_str();
38+
return Some((name, receiver, args, path.ident.span, call_span));
39+
}
40+
None
41+
}
42+
2443
pub fn match_def_paths(cx: &LateContext<'_>, did: DefId, syms: &[&[&str]]) -> bool {
2544
let path = cx.get_def_path(did);
2645
syms.iter().any(|syms| {
@@ -84,6 +103,17 @@ pub fn is_no_gc_scope_ty(cx: &LateContext<'_>, ty: &Ty) -> bool {
84103
}
85104
}
86105

106+
pub fn is_scoped_ty(cx: &LateContext<'_>, ty: &Ty) -> bool {
107+
match ty.kind() {
108+
TyKind::Adt(def, _) => match_def_path(
109+
cx,
110+
def.did(),
111+
&["nova_vm", "engine", "rootable", "scoped", "Scoped"],
112+
),
113+
_ => false,
114+
}
115+
}
116+
87117
pub fn is_value_ty(cx: &LateContext<'_>, ty: &Ty) -> bool {
88118
match ty.peel_refs().kind() {
89119
TyKind::Adt(def, _) => match_def_path(

nova_lint/ui/agent_comes_first.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#![allow(dead_code, unused_variables, clippy::disallowed_names)]
22

3-
type Agent = nova_vm::ecmascript::Agent;
3+
use nova_vm::ecmascript::Agent;
44

55
fn test_no_params() {
66
unimplemented!()

nova_lint/ui/gc_scope_comes_last.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
can_use_no_gc_scope
77
)]
88

9-
type GcScope<'a, 'b> = nova_vm::engine::GcScope<'a, 'b>;
10-
type NoGcScope<'a, 'b> = nova_vm::engine::NoGcScope<'a, 'b>;
9+
use nova_vm::engine::{GcScope, NoGcScope};
1110

1211
fn test_no_params() {
1312
unimplemented!()

0 commit comments

Comments
 (0)