Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion nova_lint/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ path = "ui/gc_scope_comes_last.rs"
name = "gc_scope_is_only_passed_by_value"
path = "ui/gc_scope_is_only_passed_by_value.rs"

[[example]]
name = "immediately_bind_scoped"
path = "ui/immediately_bind_scoped.rs"

[dependencies]
clippy_utils = { git = "https://github.com/rust-lang/rust-clippy", rev = "0450db33a5d8587f7c1d4b6d233dac963605766b" }
clippy_utils = { git = "https://github.com/rust-lang/rust-clippy", rev = "334fb906aef13d20050987b13448f37391bb97a2" }
dylint_linting = { version = "4.1.0", features = ["constituent"] }

[dev-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion nova_lint/rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[toolchain]
channel = "nightly-2025-05-14"
channel = "nightly-2025-08-07"
components = ["llvm-tools-preview", "rustc-dev"]
2 changes: 1 addition & 1 deletion nova_lint/src/agent_comes_first.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use clippy_utils::{diagnostics::span_lint_and_help, is_self};
use rustc_hir::{def_id::LocalDefId, intravisit::FnKind, Body, FnDecl};
use rustc_hir::{Body, FnDecl, def_id::LocalDefId, intravisit::FnKind};
use rustc_lint::{LateContext, LateLintPass};
use rustc_span::Span;

Expand Down
2 changes: 1 addition & 1 deletion nova_lint/src/gc_scope_comes_last.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use clippy_utils::diagnostics::span_lint_and_help;
use rustc_hir::{def_id::LocalDefId, intravisit::FnKind, Body, FnDecl};
use rustc_hir::{Body, FnDecl, def_id::LocalDefId, intravisit::FnKind};
use rustc_lint::{LateContext, LateLintPass};
use rustc_span::Span;

Expand Down
2 changes: 1 addition & 1 deletion nova_lint/src/gc_scope_is_only_passed_by_value.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use clippy_utils::{diagnostics::span_lint_and_help, is_self};
use rustc_hir::{def_id::LocalDefId, intravisit::FnKind, Body, FnDecl};
use rustc_hir::{Body, FnDecl, def_id::LocalDefId, intravisit::FnKind};
use rustc_lint::{LateContext, LateLintPass};
use rustc_middle::ty::TyKind;
use rustc_span::Span;
Expand Down
226 changes: 226 additions & 0 deletions nova_lint/src/immediately_bind_scoped.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
use std::ops::ControlFlow;

use crate::{is_scoped_ty, method_call};
use clippy_utils::{
diagnostics::span_lint_and_help,
get_enclosing_block, get_parent_expr, path_to_local_id,
paths::{PathNS, lookup_path_str},
ty::implements_trait,
visitors::for_each_expr,
};

use rustc_hir::{Expr, ExprKind, HirId, Node, PatKind, StmtKind};
use rustc_lint::{LateContext, LateLintPass};
use rustc_middle::ty::Ty;

dylint_linting::declare_late_lint! {
/// ### What it does
///
/// Makes sure that the user immediately binds `Scoped<Value>::get` results.
///
/// ### Why is this bad?
///
/// TODO: Write an explanation of why this is bad.
///
/// ### Example
///
/// ```
/// let a = scoped_a.get(agent);
/// ```
///
/// Use instead:
///
/// ```
/// let a = scoped_a.get(agent).bind(gc.nogc());
/// ```
///
/// Which ensures that no odd bugs occur.
///
/// ### Exception: If the result is immediately used without assigning to a
/// variable, binding can be skipped.
///
/// ```
/// scoped_a.get(agent).internal_delete(agent, scoped_b.get(agent), gc.reborrow());
/// ```
///
/// Here it is perfectly okay to skip the binding for both `scoped_a` and
/// `scoped_b` as the borrow checker would force you to again unbind both
/// `Value`s immediately.
pub IMMEDIATELY_BIND_SCOPED,
Deny,
"the result of `Scoped<Value>::get` should be immediately bound"
}

impl<'tcx> LateLintPass<'tcx> for ImmediatelyBindScoped {
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
// First we check if we have found a `Scoped<Value>::get` call
if is_scoped_get_method_call(cx, expr) {
// Which is followed by a trait method call to `bind` in which case
// it is all done properly and we can exit out of the lint.
if let Some(parent) = get_parent_expr(cx, expr)
&& is_bindable_bind_method_call(cx, parent)
{
return;
}

// Check if the unbound value is used in an argument position of a
// method or function call where binding can be safely skipped.
if is_in_argument_position(cx, expr) {
return;
}

// If the expression is assigned to a local variable, we need to
// check that it's next use is binding or as a function argument.
if let Some(local_hir_id) = get_assigned_local(cx, expr)
&& let Some(enclosing_block) = get_enclosing_block(cx, expr.hir_id)
{
let mut found_valid_next_use = false;

// Look for the next use of this local after the current expression.
// We need to traverse the statements in the block to find proper usage
for stmt in enclosing_block
.stmts
.iter()
.skip_while(|s| s.span.lo() < expr.span.hi())
{
// Extract relevant expressions from the statement and check
// it for a use valid of the local variable.
let Some(stmt_expr) = (match &stmt.kind {
StmtKind::Expr(expr) | StmtKind::Semi(expr) => Some(*expr),
StmtKind::Let(local) => local.init,
_ => None,
}) else {
continue;
};

// Check each expression in the current statement for use
// of the value, breaking when found and optionally marking
// it as valid.
if for_each_expr(cx, stmt_expr, |expr_in_stmt| {
if path_to_local_id(expr_in_stmt, local_hir_id) {
if is_valid_use_of_unbound_value(cx, expr_in_stmt, local_hir_id) {
found_valid_next_use = true;
}

return ControlFlow::Break(true);
}
ControlFlow::Continue(())
})
.unwrap_or(false)
{
break;
}
}

if !found_valid_next_use {
span_lint_and_help(
cx,
IMMEDIATELY_BIND_SCOPED,
expr.span,
"the result of `Scoped<Value>::get` should be immediately bound",
None,
"immediately bind the value",
);
}
}
}
}
}

/// Check if an expression is assigned to a local variable and return the local's HirId
fn get_assigned_local(cx: &LateContext<'_>, expr: &Expr) -> Option<HirId> {
let parent_node = cx.tcx.parent_hir_id(expr.hir_id);

if let Node::LetStmt(local) = cx.tcx.hir_node(parent_node)
&& let Some(init) = local.init
&& init.hir_id == expr.hir_id
&& let PatKind::Binding(_, hir_id, _, _) = local.pat.kind
{
Some(hir_id)
} else {
None
}
}

/// Check if a use of an unbound value is valid (binding or function argument)
fn is_valid_use_of_unbound_value(cx: &LateContext<'_>, expr: &Expr, hir_id: HirId) -> bool {
// Check if we're in a method call and if so, check if it's a bind call
if let Some(parent) = get_parent_expr(cx, expr)
&& is_bindable_bind_method_call(cx, parent)
{
return true;
}

// If this is a method call to bind() on our local, it's valid
if is_bindable_bind_method_call(cx, expr) {
return true;
}

// If this is the local being used as a function argument, it's valid
if path_to_local_id(expr, hir_id) && is_in_argument_position(cx, expr) {
return true;
}

false
}

/// Check if an expression is in an argument position where binding can be skipped
fn is_in_argument_position(cx: &LateContext<'_>, expr: &Expr) -> bool {
let mut current_expr = expr;

// Walk up the parent chain to see if we're in a function call argument
while let Some(parent) = get_parent_expr(cx, current_expr) {
match parent.kind {
// If we find a method call where our expression is an argument (not receiver)
ExprKind::MethodCall(_, receiver, args, _) => {
if receiver.hir_id != current_expr.hir_id
&& args.iter().any(|arg| arg.hir_id == current_expr.hir_id)
{
return true;
}
}
// If we find a function call where our expression is an argument
ExprKind::Call(_, args) => {
if args.iter().any(|arg| arg.hir_id == current_expr.hir_id) {
return true;
}
}
// Continue walking up for other expression types
_ => {}
}
current_expr = parent;
}

false
}

fn is_scoped_get_method_call(cx: &LateContext<'_>, expr: &Expr) -> bool {
if let Some((method, recv, _, _, _)) = method_call(expr)
&& method == "get"
&& let typeck_results = cx.typeck_results()
&& let recv_ty = typeck_results.expr_ty(recv)
&& is_scoped_ty(cx, &recv_ty)
{
true
} else {
false
}
}

fn is_bindable_bind_method_call(cx: &LateContext<'_>, expr: &Expr) -> bool {
if let Some((method, _, _, _, _)) = method_call(expr)
&& method == "bind"
&& let expr_ty = cx.typeck_results().expr_ty(expr)
&& implements_bindable_trait(cx, &expr_ty)
{
true
} else {
false
}
}

fn implements_bindable_trait<'tcx>(cx: &LateContext<'tcx>, ty: &Ty<'tcx>) -> bool {
lookup_path_str(cx.tcx, PathNS::Type, "nova_vm::engine::context::Bindable")
.first()
.is_some_and(|&trait_def_id| implements_trait(cx, *ty, trait_def_id, &[]))
}
2 changes: 2 additions & 0 deletions nova_lint/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ extern crate rustc_trait_selection;
mod agent_comes_first;
mod gc_scope_comes_last;
mod gc_scope_is_only_passed_by_value;
mod immediately_bind_scoped;
mod utils;

pub(crate) use utils::*;
Expand All @@ -34,6 +35,7 @@ pub fn register_lints(sess: &rustc_session::Session, lint_store: &mut rustc_lint
agent_comes_first::register_lints(sess, lint_store);
gc_scope_comes_last::register_lints(sess, lint_store);
gc_scope_is_only_passed_by_value::register_lints(sess, lint_store);
immediately_bind_scoped::register_lints(sess, lint_store);
}

#[test]
Expand Down
34 changes: 32 additions & 2 deletions nova_lint/src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use rustc_hir::def_id::DefId;
use rustc_hir::{Expr, ExprKind, def_id::DefId};
use rustc_lint::LateContext;
use rustc_middle::ty::{Ty, TyKind};
use rustc_span::symbol::Symbol;
use rustc_span::{Span, symbol::Symbol};

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

// Copyright (c) 2014-2025 The Rust Project Developers
//
// Originally copied from `dylint` which in turn copied it from `clippy_lints`:
// - https://github.com/trailofbits/dylint/blob/d1be1c42f363ca11f8ebce0ff0797ecbbcc3680b/examples/restriction/collapsible_unwrap/src/lib.rs#L180
// - https://github.com/rust-lang/rust-clippy/blob/3f015a363020d3811e1f028c9ce4b0705c728289/clippy_lints/src/methods/mod.rs#L3293-L3304
/// Extracts a method call name, args, and `Span` of the method name.
pub fn method_call<'tcx>(
recv: &'tcx Expr<'tcx>,
) -> Option<(&'tcx str, &'tcx Expr<'tcx>, &'tcx [Expr<'tcx>], Span, Span)> {
if let ExprKind::MethodCall(path, receiver, args, call_span) = recv.kind
&& !args.iter().any(|e| e.span.from_expansion())
&& !receiver.span.from_expansion()
{
let name = path.ident.name.as_str();
return Some((name, receiver, args, path.ident.span, call_span));
}
None
}

pub fn is_param_ty(ty: &Ty) -> bool {
matches!(ty.kind(), TyKind::Param(_))
}
Expand Down Expand Up @@ -53,3 +72,14 @@ pub fn is_no_gc_scope_ty(cx: &LateContext<'_>, ty: &Ty) -> bool {
_ => false,
}
}

pub fn is_scoped_ty(cx: &LateContext<'_>, ty: &Ty) -> bool {
match ty.kind() {
TyKind::Adt(def, _) => match_def_path(
cx,
def.did(),
&["nova_vm", "engine", "rootable", "scoped", "Scoped"],
),
_ => false,
}
}
60 changes: 60 additions & 0 deletions nova_lint/ui/immediately_bind_scoped.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#![allow(dead_code, unused_variables, clippy::disallowed_names)]

use nova_vm::{
ecmascript::{execution::Agent, types::Value},
engine::{
Scoped,
context::{Bindable, NoGcScope},
},
};

fn test_scoped_get_is_immediately_bound(agent: &Agent, scoped: Scoped<Value>, gc: NoGcScope) {
let _a = scoped.get(agent).bind(gc);
}

fn test_scoped_get_can_get_bound_right_after(agent: &Agent, scoped: Scoped<Value>, gc: NoGcScope) {
let a = scoped.get(agent);
a.bind(gc);
}

fn test_scoped_get_can_get_bound_in_tuple_right_after(
agent: &Agent,
scoped: Scoped<Value>,
gc: NoGcScope,
) {
let a = scoped.get(agent);
(a.bind(gc), ());
}

fn test_scoped_get_can_be_immediately_passed_on(
agent: &Agent,
scoped: Scoped<Value>,
gc: NoGcScope,
) {
let a = scoped.get(agent);
test_consumes_unbound_value(a);
}

fn test_consumes_unbound_value(value: Value) {
unimplemented!()
}

fn test_scoped_get_is_not_immediately_bound(agent: &Agent, scoped: Scoped<Value>) {
let _a = scoped.get(agent);
}

fn test_scoped_get_doesnt_need_to_be_bound_if_not_assigned(agent: &Agent, scoped: Scoped<Value>) {
scoped.get(agent);
}

fn test_improbable_but_technically_bad_situation(
agent: &Agent,
scoped: Scoped<Value>,
gc: NoGcScope,
) {
let _a = Scoped::new(agent, Value::Undefined, gc).get(agent);
}

fn main() {
unimplemented!()
}
Loading
Loading