Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b4c8f25
ty: report unused bindings as unnecessary hint diagnostics
denyszhak Feb 16, 2026
34f5f97
ty: guard analysis when errors reported like during partial assignment
denyszhak Feb 16, 2026
3a4f0ee
ty: refactor to derive unused-binding diagnostics from use-def map
denyszhak Feb 16, 2026
683ee63
ty: tiny readability improvements
denyszhak Feb 17, 2026
ecfeea3
ty: use method pointer for except-handler binding range
denyszhak Feb 17, 2026
8fc2ebd
ty: improve unused-binding capture for enclosed scope and more tests
denyszhak Feb 19, 2026
9dc375b
ty: introduce hint diagnostics and use them for unused bindings
denyszhak Feb 19, 2026
ac252e5
ty: fixes per feedback
denyszhak Feb 25, 2026
9351aca
ty: move unused_bindings to ide_support and cleanup
denyszhak Feb 26, 2026
04ad9fd
ty: get rid of Hint diagnostic
denyszhak Mar 10, 2026
c34ebad
ty: small cleanup
denyszhak Mar 10, 2026
1d31823
[ty] fix several false positives and some minor fixes
denyszhak Mar 19, 2026
4a8fd05
[ty] func name change
denyszhak Mar 19, 2026
6bab05a
[ty] move unused_bindings to it's separate module, plus few more fixes
denyszhak Mar 23, 2026
e0f6300
[ty] move override suppression to loop and compute lazily
denyszhak Mar 23, 2026
67418d4
[ty] cover overload for module level, omit lsp code reporting
denyszhak Mar 25, 2026
f02d9b6
Update crates/ty_python_semantic/src/types/ide_support.rs
denyszhak Mar 25, 2026
183cff0
[ty] get back to module alias
denyszhak Mar 25, 2026
7a8c1b1
[ty] don't quit early on parsing errors
denyszhak Mar 26, 2026
51a2da1
[ty] we publish unused-bindings for notebooks even when syntax errors…
denyszhak Mar 26, 2026
c7a1d99
[ty] refactor for shared api
denyszhak Mar 27, 2026
2d07cfa
[ty] don't apply override supression
denyszhak Mar 27, 2026
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
8 changes: 8 additions & 0 deletions crates/ruff_python_ast/src/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,14 @@ pub fn is_stub_body(body: &[Stmt]) -> bool {
})
}

/// Returns `body` without its leading docstring statement, if present.
pub fn body_without_leading_docstring(body: &[Stmt]) -> &[Stmt] {
match body.split_first() {
Some((first, rest)) if is_docstring_stmt(first) => rest,
_ => body,
}
}

/// Check if a node is part of a conditional branch.
pub fn on_conditional_branch<'a>(parents: &mut impl Iterator<Item = &'a Stmt>) -> bool {
parents.any(|parent| {
Expand Down
91 changes: 88 additions & 3 deletions crates/ty_python_semantic/src/semantic_index/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,24 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
self.current_scope_info().file_scope_id
}

/// Returns an iterator over ancestors of `scope` that are visible for name resolution,
/// starting with `scope` itself. This follows Python's lexical scoping rules where
/// class scopes are skipped during name resolution (except for the starting scope
/// if it happens to be a class scope).
///
/// For example, in this code:
/// ```python
/// x = 1
/// class A:
/// x = 2
/// def method(self):
/// print(x) # Refers to global x=1, not class x=2
/// ```
/// The `method` function can see the global scope but not the class scope.
fn visible_ancestor_scopes(&self, scope: FileScopeId) -> VisibleAncestorsIter<'_> {
VisibleAncestorsIter::new(&self.scopes, scope)
}

/// Returns the scope ID of the current scope if the current scope
/// is a method inside a class body or an eagerly executed scope inside a method.
/// Returns `None` otherwise, e.g. if the current scope is a function body outside of a class, or if the current scope is not a
Expand Down Expand Up @@ -499,9 +517,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
.symbol(enclosing_symbol)
.name();
let is_reassignment_of_snapshotted_symbol = || {
for (ancestor, _) in
VisibleAncestorsIter::new(&self.scopes, key.enclosing_scope)
{
for (ancestor, _) in self.visible_ancestor_scopes(key.enclosing_scope) {
if ancestor == current_scope {
return true;
}
Expand Down Expand Up @@ -557,6 +573,74 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
});
}

/// Finds the nearest visible ancestor scope that actually owns a local binding for `name`.
fn resolve_nested_reference_scope(
&self,
nested_scope: FileScopeId,
name: &str,
) -> Option<FileScopeId> {
self.visible_ancestor_scopes(nested_scope)
.skip(1)
.find_map(|(scope_id, _)| {
let place_table = &self.place_tables[scope_id];
let symbol_id = place_table.symbol_id(name)?;
let symbol = place_table.symbol(symbol_id);

// Only a true local binding in an ancestor scope can be the resolution target.
// `global`/`nonlocal` here are forwarding declarations, not owning bindings.
symbol.is_local().then_some(scope_id)
})
}

/// Marks bindings in enclosing scopes as used when a nested scope resolves a reference to them.
///
/// This reuses enclosing-snapshot data so lazy scopes account for later reassignments that can
/// also reach the nested reference.
fn mark_captured_bindings_used(&mut self) {
let mut resolved_scopes_by_nested_symbol =
FxHashMap::<(FileScopeId, ScopedSymbolId), Option<FileScopeId>>::default();

let mut snapshots_to_mark = Vec::new();

for (&key, &snapshot_id) in &self.enclosing_snapshots {
let ScopedPlaceId::Symbol(enclosing_symbol_id) = key.enclosing_place else {
continue;
};

let enclosing_symbol =
self.place_tables[key.enclosing_scope].symbol(enclosing_symbol_id);
let nested_place_table = &self.place_tables[key.nested_scope];

let Some(nested_symbol_id) =
nested_place_table.symbol_id(enclosing_symbol.name().as_str())
else {
continue;
};

let nested_symbol = nested_place_table.symbol(nested_symbol_id);
if !nested_symbol.is_used() || nested_symbol.is_local() || nested_symbol.is_global() {
continue;
}

let resolved_scope = *resolved_scopes_by_nested_symbol
.entry((key.nested_scope, nested_symbol_id))
.or_insert_with(|| {
self.resolve_nested_reference_scope(
key.nested_scope,
enclosing_symbol.name().as_str(),
)
});

if resolved_scope == Some(key.enclosing_scope) {
snapshots_to_mark.push((key.enclosing_scope, snapshot_id));
}
}

for (scope_id, snapshot_id) in snapshots_to_mark {
self.use_def_maps[scope_id].mark_enclosing_snapshot_bindings_used(snapshot_id);
}
}

fn pop_scope(&mut self) -> FileScopeId {
self.try_node_context_stack_manager.exit_scope();

Expand Down Expand Up @@ -1650,6 +1734,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {

// Pop the root scope
self.pop_scope();
self.mark_captured_bindings_used();
self.sweep_nonlocal_lazy_snapshots();
assert!(self.scope_stack.is_empty());

Expand Down
15 changes: 14 additions & 1 deletion crates/ty_python_semantic/src/semantic_index/definition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,15 @@ impl DefinitionKind<'_> {
matches!(self, DefinitionKind::Function(_))
}

pub(crate) const fn is_parameter_def(&self) -> bool {
matches!(
self,
DefinitionKind::VariadicPositionalParameter(_)
| DefinitionKind::VariadicKeywordParameter(_)
| DefinitionKind::Parameter(_)
)
}

pub(crate) const fn is_loop_header(&self) -> bool {
matches!(self, DefinitionKind::LoopHeader(_))
}
Expand Down Expand Up @@ -908,7 +917,11 @@ impl DefinitionKind<'_> {
DefinitionKind::MatchPattern(match_pattern) => {
match_pattern.identifier.node(module).range()
}
DefinitionKind::ExceptHandler(handler) => handler.node(module).range(),
DefinitionKind::ExceptHandler(handler) => handler
.node(module)
.name
.as_ref()
.map_or_else(|| handler.node(module).range(), Ranged::range),
DefinitionKind::TypeVar(type_var) => type_var.node(module).name.range(),
DefinitionKind::ParamSpec(param_spec) => param_spec.node(module).name.range(),
DefinitionKind::TypeVarTuple(type_var_tuple) => {
Expand Down
86 changes: 76 additions & 10 deletions crates/ty_python_semantic/src/semantic_index/use_def.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,11 @@ pub(crate) struct UseDefMap<'db> {
/// this represents the implicit "unbound"/"undeclared" definition of every place.
all_definitions: IndexVec<ScopedDefinitionId, DefinitionState<'db>>,

/// A bitset-like map indicating whether each binding definition has at least one use.
///
/// This uses the same index as `all_definitions`.
used_bindings: IndexVec<ScopedDefinitionId, bool>,

/// Array of predicates in this scope.
predicates: Predicates<'db>,

Expand Down Expand Up @@ -394,6 +399,14 @@ pub(crate) enum ApplicableConstraints<'map, 'db> {
}

impl<'db> UseDefMap<'db> {
pub(crate) fn all_definitions_with_usage(
&self,
) -> impl Iterator<Item = (ScopedDefinitionId, DefinitionState<'db>, bool)> + '_ {
self.all_definitions
.iter_enumerated()
.map(|(id, &state)| (id, state, self.used_bindings[id]))
}

pub(crate) fn bindings_at_use(
&self,
use_id: ScopedUseId,
Expand Down Expand Up @@ -895,6 +908,11 @@ pub(super) struct UseDefMapBuilder<'db> {
/// Append-only array of [`DefinitionState`].
all_definitions: IndexVec<ScopedDefinitionId, DefinitionState<'db>>,

/// Tracks whether each binding definition has at least one use.
///
/// Uses the same index as `all_definitions`.
used_bindings: IndexVec<ScopedDefinitionId, bool>,

/// Builder of predicates.
pub(super) predicates: PredicatesBuilder<'db>,

Expand Down Expand Up @@ -947,6 +965,7 @@ impl<'db> UseDefMapBuilder<'db> {
pub(super) fn new(is_class_scope: bool) -> Self {
Self {
all_definitions: IndexVec::from_iter([DefinitionState::Undefined]),
used_bindings: IndexVec::from_iter([false]),
predicates: PredicatesBuilder::default(),
reachability_constraints: ReachabilityConstraintsBuilder::default(),
bindings_by_use: IndexVec::new(),
Expand All @@ -964,6 +983,13 @@ impl<'db> UseDefMapBuilder<'db> {
}
}

fn push_definition(&mut self, state: DefinitionState<'db>) -> ScopedDefinitionId {
let def_id = self.all_definitions.push(state);
let used_id = self.used_bindings.push(false);
debug_assert_eq!(def_id, used_id);
def_id
}

pub(super) fn mark_unreachable(&mut self) {
self.reachability = ScopedReachabilityConstraintId::ALWAYS_FALSE;

Expand Down Expand Up @@ -1031,7 +1057,7 @@ impl<'db> UseDefMapBuilder<'db> {
self.bindings_by_definition
.insert(binding, bindings.clone());

let def_id = self.all_definitions.push(DefinitionState::Defined(binding));
let def_id = self.push_definition(DefinitionState::Defined(binding));
let place_state = match place {
ScopedPlaceId::Symbol(symbol) => &mut self.symbol_states[symbol],
ScopedPlaceId::Member(member) => &mut self.member_states[member],
Expand Down Expand Up @@ -1279,9 +1305,7 @@ impl<'db> UseDefMapBuilder<'db> {
place: ScopedPlaceId,
declaration: Definition<'db>,
) {
let def_id = self
.all_definitions
.push(DefinitionState::Defined(declaration));
let def_id = self.push_definition(DefinitionState::Defined(declaration));

let place_state = match place {
ScopedPlaceId::Symbol(symbol) => &mut self.symbol_states[symbol],
Expand Down Expand Up @@ -1311,9 +1335,7 @@ impl<'db> UseDefMapBuilder<'db> {
) {
// We don't need to store anything in self.bindings_by_declaration or
// self.declarations_by_binding.
let def_id = self
.all_definitions
.push(DefinitionState::Defined(definition));
let def_id = self.push_definition(DefinitionState::Defined(definition));
let place_state = match place {
ScopedPlaceId::Symbol(symbol) => &mut self.symbol_states[symbol],
ScopedPlaceId::Member(member) => &mut self.member_states[member],
Expand Down Expand Up @@ -1347,7 +1369,7 @@ impl<'db> UseDefMapBuilder<'db> {
}

pub(super) fn delete_binding(&mut self, place: ScopedPlaceId) {
let def_id = self.all_definitions.push(DefinitionState::Deleted);
let def_id = self.push_definition(DefinitionState::Deleted);
let place_state = match place {
ScopedPlaceId::Symbol(symbol) => &mut self.symbol_states[symbol],
ScopedPlaceId::Member(member) => &mut self.member_states[member],
Expand Down Expand Up @@ -1380,25 +1402,45 @@ impl<'db> UseDefMapBuilder<'db> {
let bindings = match place {
ScopedPlaceId::Symbol(symbol) => self.symbol_states[symbol].bindings(),
ScopedPlaceId::Member(member) => self.member_states[member].bindings(),
};
}
.clone();

let binding_definition_ids = bindings.iter().map(|live_binding| live_binding.binding);
self.mark_definition_ids_used(binding_definition_ids);

self.multi_bindings_by_use
.entry(use_id)
.or_default()
.push(bindings.clone());
.push(bindings);
}

// Record a placeholder use of the parent expression to preserve the indices of `bindings_by_use`.
self.record_use_bindings(Bindings::default(), use_id);
}

fn record_use_bindings(&mut self, bindings: Bindings, use_id: ScopedUseId) {
let binding_definition_ids = bindings.iter().map(|live_binding| live_binding.binding);
self.mark_definition_ids_used(binding_definition_ids);

// We have a use of a place; clone the current bindings for that place, and record them
// as the live bindings for this use.
let new_use = self.bindings_by_use.push(bindings);
debug_assert_eq!(use_id, new_use);
}

pub(super) fn mark_enclosing_snapshot_bindings_used(
&mut self,
snapshot_id: ScopedEnclosingSnapshotId,
) {
let Some(EnclosingSnapshot::Bindings(bindings)) = self.enclosing_snapshots.get(snapshot_id)
else {
return;
};

let binding_definition_ids = bindings.iter().map(|b| b.binding).collect::<Vec<_>>();
self.mark_definition_ids_used(binding_definition_ids.into_iter());
}

pub(super) fn record_range_reachability(&mut self, range: TextRange) {
// If the last entry has the same reachability constraint, extend it
// to cover this range too, collapsing consecutive statements in the
Expand Down Expand Up @@ -1459,6 +1501,28 @@ impl<'db> UseDefMapBuilder<'db> {
}
}

fn mark_definition_ids_used(
&mut self,
definition_ids: impl Iterator<Item = ScopedDefinitionId>,
) {
for definition_id in definition_ids {
self.mark_definition_used(definition_id);
}
}

fn mark_definition_used(&mut self, definition_id: ScopedDefinitionId) {
if definition_id.is_unbound() {
return;
}

if matches!(
self.all_definitions[definition_id],
DefinitionState::Defined(_)
) {
self.used_bindings[definition_id] = true;
}
}

/// Take a snapshot of the current visible-places state.
pub(super) fn snapshot(&self) -> FlowSnapshot {
FlowSnapshot {
Expand Down Expand Up @@ -1565,6 +1629,7 @@ impl<'db> UseDefMapBuilder<'db> {

pub(super) fn finish(mut self) -> UseDefMap<'db> {
self.all_definitions.shrink_to_fit();
self.used_bindings.shrink_to_fit();
self.symbol_states.shrink_to_fit();
self.member_states.shrink_to_fit();
self.reachable_symbol_definitions.shrink_to_fit();
Expand Down Expand Up @@ -1657,6 +1722,7 @@ impl<'db> UseDefMapBuilder<'db> {

UseDefMap {
all_definitions: self.all_definitions,
used_bindings: self.used_bindings,
predicates: self.predicates.build(),
reachability_constraints: self.reachability_constraints.build(),
interned_bindings,
Expand Down
25 changes: 13 additions & 12 deletions crates/ty_python_semantic/src/types/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,17 @@ fn last_definition_signature_cycle_initial<'db>(
Signature::bottom()
}

/// Returns `true` if the function body is stub-like, ignoring a leading docstring.
pub(crate) fn function_has_stub_body(node: &ast::StmtFunctionDef) -> bool {
let suite = ast::helpers::body_without_leading_docstring(&node.body);

suite.iter().all(|stmt| match stmt {
ast::Stmt::Pass(_) => true,
ast::Stmt::Expr(ast::StmtExpr { value, .. }) => value.is_ellipsis_literal_expr(),
_ => false,
})
}

/// Classify the body of this function:
/// - [`FunctionBodyKind::Stub`] if it is a stub function (i.e., only contains `pass` or `...`
/// - [`FunctionBodyKind::AlwaysRaisesNotImplementedError`] if it consists of a single
Expand All @@ -1605,19 +1616,9 @@ pub(super) fn function_body_kind<'db>(
infer_type: impl Fn(&ast::Expr) -> Type<'db>,
) -> FunctionBodyKind {
// Allow docstrings, but only as the first statement.
let suite = if let Some(ast::Stmt::Expr(ast::StmtExpr { value, .. })) = node.body.first()
&& value.is_string_literal_expr()
{
&node.body[1..]
} else {
&node.body[..]
};
let suite = ast::helpers::body_without_leading_docstring(&node.body);

if suite.iter().all(|stmt| match stmt {
ast::Stmt::Pass(_) => true,
ast::Stmt::Expr(ast::StmtExpr { value, .. }) => value.is_ellipsis_literal_expr(),
_ => false,
}) {
if function_has_stub_body(node) {
return FunctionBodyKind::Stub;
}

Expand Down
Loading
Loading