@@ -6,7 +6,7 @@ use ruff_db::parsed::parsed_module;
66use ruff_python_ast:: name:: Name ;
77use ruff_text_size:: TextRange ;
88use rustc_hash:: FxHashSet ;
9- use ty_python_core:: definition:: { DefinitionKind , DefinitionState } ;
9+ use ty_python_core:: definition:: { DefinitionCategory , DefinitionKind , DefinitionState } ;
1010use ty_python_core:: place:: ScopedPlaceId ;
1111use ty_python_core:: scope:: { FileScopeId , ScopeKind } ;
1212use 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) ) ]
7273pub 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