Skip to content

Commit 80feb29

Browse files
authored
[ty] report only dead annotation-only locals as unused (#24811)
1 parent 0fbf2bc commit 80feb29

1 file changed

Lines changed: 66 additions & 2 deletions

File tree

crates/ty_python_semantic/src/types/ide_support/unused_bindings.rs

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use ruff_db::parsed::parsed_module;
66
use ruff_python_ast::name::Name;
77
use ruff_text_size::TextRange;
88
use rustc_hash::FxHashSet;
9-
use ty_python_core::definition::{DefinitionKind, DefinitionState};
9+
use ty_python_core::definition::{DefinitionCategory, DefinitionKind, DefinitionState};
1010
use ty_python_core::place::ScopedPlaceId;
1111
use ty_python_core::scope::{FileScopeId, ScopeKind};
1212
use ty_python_core::{SemanticIndex, get_loop_header, semantic_index};
@@ -67,7 +67,8 @@ pub struct UnusedBinding {
6767
/// This intentionally reports only function-, lambda-, and comprehension-scope bindings.
6868
/// Module- and class-scope bindings can still be observed indirectly (for example via
6969
/// imports or attribute access), so reporting them here would risk false positives
70-
/// without broader reference analysis.
70+
/// without broader reference analysis. Bare local annotations (`x: int`) are also
71+
/// reported, but only if the symbol is neither bound nor used elsewhere in the scope.
7172
#[salsa::tracked(returns(ref))]
7273
pub fn unused_bindings(db: &dyn Db, file: ruff_db::files::File) -> Vec<UnusedBinding> {
7374
let parsed = parsed_module(db, file).load(db);
@@ -159,6 +160,13 @@ pub fn unused_bindings(db: &dyn Db, file: ruff_db::files::File) -> Vec<UnusedBin
159160
continue;
160161
}
161162

163+
let category = kind.category(is_stub_file, &parsed);
164+
if matches!(category, DefinitionCategory::Declaration)
165+
&& (symbol.is_bound() || symbol.is_used())
166+
{
167+
continue;
168+
}
169+
162170
let range = kind.target_range(&parsed);
163171

164172
unused.push(UnusedBinding {
@@ -686,6 +694,62 @@ mod tests {
686694
Ok(())
687695
}
688696

697+
#[test]
698+
fn skips_annotation_only_declaration_before_reassignment() -> anyhow::Result<()> {
699+
let source = dedent(
700+
"
701+
def fn(value: bool):
702+
a: int
703+
if value:
704+
a = 1
705+
else:
706+
a = 2
707+
708+
return a
709+
",
710+
);
711+
712+
let names = collect_unused_names(&source)?;
713+
assert_eq!(names, Vec::<String>::new());
714+
Ok(())
715+
}
716+
717+
#[test]
718+
fn skips_annotation_only_declaration_before_unused_binding() -> anyhow::Result<()> {
719+
let source = dedent(
720+
"
721+
def fn():
722+
a: int
723+
a = 1
724+
",
725+
);
726+
727+
let bindings = collect_unused_bindings(&source)?;
728+
let assignment_start = TextSize::try_from(source.rfind("a = 1").unwrap()).unwrap();
729+
assert_eq!(
730+
bindings,
731+
vec![UnusedBinding {
732+
range: TextRange::new(assignment_start, assignment_start + TextSize::new(1)),
733+
name: Name::new("a"),
734+
}]
735+
);
736+
Ok(())
737+
}
738+
739+
#[test]
740+
fn reports_dead_annotation_only_declaration() -> anyhow::Result<()> {
741+
let source = dedent(
742+
"
743+
def fn():
744+
a: int
745+
",
746+
);
747+
748+
let names = collect_unused_names(&source)?;
749+
assert_eq!(names, vec!["a"]);
750+
Ok(())
751+
}
752+
689753
#[test]
690754
fn skips_unreachable_loop_carried_rebinding() -> anyhow::Result<()> {
691755
let source = dedent(

0 commit comments

Comments
 (0)