From c6ad1e8169948ad92c15ad8b88557ef71ff2aaa6 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Thu, 12 Feb 2026 23:29:28 +0900 Subject: [PATCH 01/69] [ty] improve #23109 TDD-based narrowing --- .../mdtest/narrow/post_if_statement.md | 7 +-- .../src/semantic_index/builder.rs | 55 ++++++++----------- .../reachability_constraints.rs | 43 +++++++++++---- .../src/semantic_index/use_def/place_state.rs | 46 ++++++++++++++-- 4 files changed, 98 insertions(+), 53 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md index 76d96d746baf10..4dcee3f4d4b396 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md @@ -186,8 +186,7 @@ def _(x: int | None): return reveal_type(x) # revealed: int - # TODO: should be `int` (the else-branch of `1 + 1 == 2` is unreachable) - reveal_type(x) # revealed: int | None + reveal_type(x) # revealed: int ``` This also works when the always-true condition is nested inside a narrowing branch: @@ -198,9 +197,7 @@ def _(x: int | None): if 1 + 1 == 2: return - # TODO: should be `int` (the inner always-true branch makes the outer - # if-branch terminal) - reveal_type(x) # revealed: int | None + reveal_type(x) # revealed: int ``` ## Narrowing from `assert` should not affect reassigned variables diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index 2c70ca634a2611..e8812eb48c3289 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -1869,14 +1869,14 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { if let Some(msg) = msg { let post_test = self.flow_snapshot(); let negated_predicate = predicate.negated(); - self.record_narrowing_constraint(negated_predicate); - self.record_reachability_constraint(negated_predicate); + let predicate_id = self.record_narrowing_constraint(negated_predicate); + self.record_reachability_constraint_id(predicate_id); self.visit_expr(msg); self.flow_restore(post_test); } - self.record_narrowing_constraint(predicate); - self.record_reachability_constraint(predicate); + let predicate_id = self.record_narrowing_constraint(predicate); + self.record_reachability_constraint_id(predicate_id); } ast::Stmt::Assign(node) => { @@ -1994,7 +1994,7 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { let (mut last_predicate, mut last_narrowing_id) = self.record_expression_narrowing_constraint(&node.test); let mut last_reachability_constraint = - self.record_reachability_constraint(last_predicate); + self.record_reachability_constraint_id(last_narrowing_id); let is_outer_block_in_type_checking = self.in_type_checking_block; @@ -2045,7 +2045,7 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { self.record_expression_narrowing_constraint(elif_test); last_reachability_constraint = - self.record_reachability_constraint(last_predicate); + self.record_reachability_constraint_id(last_narrowing_id); } // Determine if this clause is in type checking context @@ -2088,7 +2088,7 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { let pre_loop = self.flow_snapshot(); let (predicate, predicate_id) = self.record_expression_narrowing_constraint(test); - self.record_reachability_constraint(predicate); + self.record_reachability_constraint_id(predicate_id); let outer_loop = self.push_loop(); self.visit_body(body); @@ -2229,36 +2229,25 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { ); previous_pattern = Some(match_pattern_predicate); let reachability_constraint = - self.record_reachability_constraint(match_predicate); + self.record_reachability_constraint_id(match_narrowing_id); let match_success_guard_failure = case.guard.as_ref().map(|guard| { - let guard_expr = self.add_standalone_expression(guard); - // We could also add the guard expression as a reachability constraint, but - // it seems unlikely that both the case predicate as well as the guard are - // statically known conditions, so we currently don't model that. - self.record_ambiguous_reachability(); self.visit_expr(guard); let post_guard_eval = self.flow_snapshot(); - let predicate = PredicateOrLiteral::Predicate(Predicate { - node: PredicateNode::Expression(guard_expr), - is_positive: true, - }); - // Add the predicate once, then use TDD-level negation for the failure - // path. This ensures the positive and negative atoms share the same ID. - let guard_predicate_id = self.add_predicate(predicate); - let possibly_narrowed = self.compute_possibly_narrowed_places(&predicate); - self.current_use_def_map_mut() - .record_negated_narrowing_constraint_for_places( - guard_predicate_id, - &possibly_narrowed, - ); - let match_success_guard_failure = self.flow_snapshot(); + let (guard_predicate, guard_predicate_id) = + self.record_expression_narrowing_constraint(guard); + let guard_reachability_constraint = + self.record_reachability_constraint_id(guard_predicate_id); + + let guard_success_state = self.flow_snapshot(); self.flow_restore(post_guard_eval); - self.current_use_def_map_mut() - .record_narrowing_constraint_for_places( - guard_predicate_id, - &possibly_narrowed, - ); + self.record_negated_narrowing_constraint( + guard_predicate, + guard_predicate_id, + ); + self.record_negated_reachability_constraint(guard_reachability_constraint); + let match_success_guard_failure = self.flow_snapshot(); + self.flow_restore(guard_success_state); match_success_guard_failure }); @@ -2808,7 +2797,7 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { self.visit_expr(test); let pre_if = self.flow_snapshot(); let (predicate, predicate_id) = self.record_expression_narrowing_constraint(test); - let reachability_constraint = self.record_reachability_constraint(predicate); + let reachability_constraint = self.record_reachability_constraint_id(predicate_id); self.visit_expr(body); let post_body = self.flow_snapshot(); self.flow_restore(pre_if); diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 5761e95249be19..cc7975650a7bfb 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -850,14 +850,42 @@ impl ReachabilityConstraints { // Check if this predicate narrows the variable we're interested in. let pos_constraint = infer_narrowing_constraint(db, predicate, place); + let neg_predicate = Predicate { + node: predicate.node, + is_positive: !predicate.is_positive, + }; + let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); + + // If this predicate does not narrow the current place and we can statically + // determine its truthiness, follow only the reachable branch. + if pos_constraint.is_none() && neg_constraint.is_none() { + match Self::analyze_single(db, &predicate) { + Truthiness::AlwaysTrue => { + return self.narrow_by_constraint_inner( + db, + predicates, + node.if_true, + base_ty, + place, + accumulated, + ); + } + Truthiness::AlwaysFalse => { + return self.narrow_by_constraint_inner( + db, + predicates, + node.if_false, + base_ty, + place, + accumulated, + ); + } + Truthiness::Ambiguous => {} + } + } // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { - let neg_predicate = Predicate { - node: predicate.node, - is_positive: !predicate.is_positive, - }; - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); return self.narrow_by_constraint_inner( db, @@ -895,11 +923,6 @@ impl ReachabilityConstraints { ); // False branch: predicate doesn't hold → accumulate negative narrowing - let neg_predicate = Predicate { - node: predicate.node, - is_positive: !predicate.is_positive, - }; - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = self.narrow_by_constraint_inner( db, diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index 033f0aa5426d34..7bcb9e38d8cbe3 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -334,15 +334,28 @@ impl Bindings { for zipped in a.merge_join_by(b, |a, b| a.binding.cmp(&b.binding)) { match zipped { EitherOrBoth::Both(a, b) => { - // If the same definition is visible through both paths, we OR the narrowing - // constraints: the type should be narrowed by whichever path was taken. - let narrowing_constraint = reachability_constraints - .add_or_constraint(a.narrowing_constraint, b.narrowing_constraint); - // For reachability constraints, we also merge using a ternary OR operation: let reachability_constraint = reachability_constraints .add_or_constraint(a.reachability_constraint, b.reachability_constraint); + // A branch contributes narrowing only when it is reachable. + // + // Without this gating, `OR(narrowing_a, narrowing_b)` allows an unreachable + // branch with `ALWAYS_TRUE` narrowing to cancel useful narrowing from the + // reachable branch. + let narrowing_constraint = if a.narrowing_constraint + == ScopedNarrowingConstraint::ALWAYS_TRUE + && b.narrowing_constraint == ScopedNarrowingConstraint::ALWAYS_TRUE + { + ScopedNarrowingConstraint::ALWAYS_TRUE + } else { + let a_gated = reachability_constraints + .add_and_constraint(a.narrowing_constraint, a.reachability_constraint); + let b_gated = reachability_constraints + .add_and_constraint(b.narrowing_constraint, b.reachability_constraint); + reachability_constraints.add_or_constraint(a_gated, b_gated) + }; + self.live_bindings.push(LiveBinding { binding: a.binding, narrowing_constraint, @@ -628,6 +641,29 @@ mod tests { assert_eq!(bindings[1].1, atom0); assert_eq!(bindings[2].0, 3); assert_eq!(bindings[2].1, atom3); + + // An unreachable branch should not dilute narrowing from the reachable branch. + let mut sym4a = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + sym4a.record_binding( + ScopedDefinitionId::from_u32(4), + ScopedReachabilityConstraintId::ALWAYS_FALSE, + false, + true, + ); + + let mut sym4b = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + sym4b.record_binding( + ScopedDefinitionId::from_u32(4), + ScopedReachabilityConstraintId::ALWAYS_TRUE, + false, + true, + ); + let atom4 = reachability_constraints.add_atom(ScopedPredicateId::new(4)); + sym4b.record_narrowing_constraint(&mut reachability_constraints, atom4); + + sym4a.merge(sym4b, &mut reachability_constraints); + let merged_constraint = sym4a.bindings().iter().next().unwrap().narrowing_constraint; + assert_eq!(merged_constraint, atom4); } #[test] From 989268db1f2e86a9eeac40c7babe9506272f3c8e Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 13 Feb 2026 00:53:50 +0900 Subject: [PATCH 02/69] cache `narrow_by_constraint_inner` --- .../reachability_constraints.rs | 68 +++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index cc7975650a7bfb..41b9045ad89971 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -791,10 +791,22 @@ impl ReachabilityConstraints { base_ty: Type<'db>, place: ScopedPlaceId, ) -> Type<'db> { - self.narrow_by_constraint_inner(db, predicates, id, base_ty, place, None) + let mut memo = FxHashMap::default(); + let mut truthiness_memo = FxHashMap::default(); + self.narrow_by_constraint_inner( + db, + predicates, + id, + base_ty, + place, + None, + &mut memo, + &mut truthiness_memo, + ) } /// Inner recursive helper that accumulates narrowing constraints along each TDD path. + #[allow(clippy::too_many_arguments)] fn narrow_by_constraint_inner<'db>( &self, db: &'db dyn Db, @@ -803,8 +815,21 @@ impl ReachabilityConstraints { base_ty: Type<'db>, place: ScopedPlaceId, accumulated: Option>, + memo: &mut FxHashMap< + ( + ScopedReachabilityConstraintId, + Option>, + ), + Type<'db>, + >, + truthiness_memo: &mut FxHashMap, Truthiness>, ) -> Type<'db> { - match id { + let key = (id, accumulated.clone()); + if let Some(cached) = memo.get(&key).copied() { + return cached; + } + + let narrowed = match id { ALWAYS_TRUE | AMBIGUOUS => { // Apply all accumulated narrowing constraints to the base type match accumulated { @@ -825,7 +850,7 @@ impl ReachabilityConstraints { // `ReturnsNever` always evaluates to `AlwaysTrue` or `AlwaysFalse`, // never `Ambiguous`. if matches!(predicate.node, PredicateNode::ReturnsNever(_)) { - return match Self::analyze_single(db, &predicate) { + return match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => self.narrow_by_constraint_inner( db, predicates, @@ -833,6 +858,8 @@ impl ReachabilityConstraints { base_ty, place, accumulated, + memo, + truthiness_memo, ), Truthiness::AlwaysFalse => self.narrow_by_constraint_inner( db, @@ -841,6 +868,8 @@ impl ReachabilityConstraints { base_ty, place, accumulated, + memo, + truthiness_memo, ), Truthiness::Ambiguous => { unreachable!("ReturnsNever predicates should never be Ambiguous") @@ -859,7 +888,7 @@ impl ReachabilityConstraints { // If this predicate does not narrow the current place and we can statically // determine its truthiness, follow only the reachable branch. if pos_constraint.is_none() && neg_constraint.is_none() { - match Self::analyze_single(db, &predicate) { + match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { return self.narrow_by_constraint_inner( db, @@ -868,6 +897,8 @@ impl ReachabilityConstraints { base_ty, place, accumulated, + memo, + truthiness_memo, ); } Truthiness::AlwaysFalse => { @@ -878,6 +909,8 @@ impl ReachabilityConstraints { base_ty, place, accumulated, + memo, + truthiness_memo, ); } Truthiness::Ambiguous => {} @@ -894,6 +927,8 @@ impl ReachabilityConstraints { base_ty, place, false_accumulated, + memo, + truthiness_memo, ); } @@ -907,6 +942,8 @@ impl ReachabilityConstraints { base_ty, place, true_accumulated, + memo, + truthiness_memo, ); } @@ -920,6 +957,8 @@ impl ReachabilityConstraints { base_ty, place, true_accumulated, + memo, + truthiness_memo, ); // False branch: predicate doesn't hold → accumulate negative narrowing @@ -931,11 +970,16 @@ impl ReachabilityConstraints { base_ty, place, false_accumulated, + memo, + truthiness_memo, ); UnionType::from_elements(db, [true_ty, false_ty]) } - } + }; + + memo.insert(key, narrowed); + narrowed } /// Analyze the statically known reachability for a given constraint. @@ -1172,4 +1216,18 @@ impl ReachabilityConstraints { } } } + + fn analyze_single_cached<'db>( + db: &'db dyn Db, + predicate: Predicate<'db>, + memo: &mut FxHashMap, Truthiness>, + ) -> Truthiness { + if let Some(cached) = memo.get(&predicate) { + return *cached; + } + + let analyzed = Self::analyze_single(db, &predicate); + memo.insert(predicate, analyzed); + analyzed + } } From 7f8379c0e6208d8f03575772ea2ce86b5e454d10 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 13 Feb 2026 01:20:06 +0900 Subject: [PATCH 03/69] refactor --- .../src/semantic_index/use_def/place_state.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index 7bcb9e38d8cbe3..976c94da864092 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -338,22 +338,22 @@ impl Bindings { let reachability_constraint = reachability_constraints .add_or_constraint(a.reachability_constraint, b.reachability_constraint); - // A branch contributes narrowing only when it is reachable. - // - // Without this gating, `OR(narrowing_a, narrowing_b)` allows an unreachable - // branch with `ALWAYS_TRUE` narrowing to cancel useful narrowing from the - // reachable branch. let narrowing_constraint = if a.narrowing_constraint == ScopedNarrowingConstraint::ALWAYS_TRUE && b.narrowing_constraint == ScopedNarrowingConstraint::ALWAYS_TRUE { ScopedNarrowingConstraint::ALWAYS_TRUE } else { - let a_gated = reachability_constraints + // A branch contributes narrowing only when it is reachable. + // Without this gating, `OR(a_narrowing, b_narrowing)` allows an unreachable + // branch with `ALWAYS_TRUE` narrowing to cancel useful narrowing from the + // reachable branch. + let a_narrowing_gated = reachability_constraints .add_and_constraint(a.narrowing_constraint, a.reachability_constraint); - let b_gated = reachability_constraints + let b_narrowing_gated = reachability_constraints .add_and_constraint(b.narrowing_constraint, b.reachability_constraint); - reachability_constraints.add_or_constraint(a_gated, b_gated) + reachability_constraints + .add_or_constraint(a_narrowing_gated, b_narrowing_gated) }; self.live_bindings.push(LiveBinding { From f92287c1e7fe99266f6edfe2ccd373d952ae52dc Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 13 Feb 2026 01:53:09 +0900 Subject: [PATCH 04/69] further `narrow_by_constraint_inner` optimization --- .../reachability_constraints.rs | 61 +++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 41b9045ad89971..1df0f61b9a1072 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -195,7 +195,9 @@ use std::cmp::Ordering; +use ruff_db::parsed::parsed_module; use ruff_index::{Idx, IndexVec}; +use ruff_python_ast as ast; use rustc_hash::FxHashMap; use crate::Db; @@ -883,11 +885,11 @@ impl ReachabilityConstraints { node: predicate.node, is_positive: !predicate.is_positive, }; - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); - // If this predicate does not narrow the current place and we can statically - // determine its truthiness, follow only the reachable branch. - if pos_constraint.is_none() && neg_constraint.is_none() { + // For predicates that don't narrow this place, try a static truthiness fast-path + // only for expressions that are likely const-expr. + // This avoids expensive analysis on dynamic predicates. + if pos_constraint.is_none() && Self::is_const_expression_predicate(db, predicate) { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { return self.narrow_by_constraint_inner( @@ -902,13 +904,17 @@ impl ReachabilityConstraints { ); } Truthiness::AlwaysFalse => { + let neg_constraint = + infer_narrowing_constraint(db, neg_predicate, place); + let false_accumulated = + accumulate_constraint(db, accumulated, neg_constraint); return self.narrow_by_constraint_inner( db, predicates, node.if_false, base_ty, place, - accumulated, + false_accumulated, memo, truthiness_memo, ); @@ -919,6 +925,7 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { + let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); return self.narrow_by_constraint_inner( db, @@ -962,6 +969,7 @@ impl ReachabilityConstraints { ); // False branch: predicate doesn't hold → accumulate negative narrowing + let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = self.narrow_by_constraint_inner( db, @@ -1230,4 +1238,47 @@ impl ReachabilityConstraints { memo.insert(predicate, analyzed); analyzed } + + /// Cheap syntactic filter for expression predicates where static truthiness is plausible. + /// + /// This is intentionally conservative and only accepts expressions composed from literals and + /// pure operators. It avoids calling expensive `analyze_single` for dynamic expressions where the + /// result is almost always ambiguous. + fn is_const_expression_predicate(db: &dyn Db, predicate: Predicate<'_>) -> bool { + fn is_const_expr(expr: &ast::Expr) -> bool { + match expr { + ast::Expr::BooleanLiteral(_) + | ast::Expr::NoneLiteral(_) + | ast::Expr::EllipsisLiteral(_) + | ast::Expr::NumberLiteral(_) + | ast::Expr::StringLiteral(_) + | ast::Expr::BytesLiteral(_) => true, + ast::Expr::UnaryOp(ast::ExprUnaryOp { operand, .. }) => is_const_expr(operand), + ast::Expr::BinOp(ast::ExprBinOp { left, right, .. }) => { + is_const_expr(left) && is_const_expr(right) + } + ast::Expr::BoolOp(ast::ExprBoolOp { values, .. }) => { + values.iter().all(is_const_expr) + } + ast::Expr::Compare(ast::ExprCompare { + left, comparators, .. + }) => is_const_expr(left) && comparators.iter().all(is_const_expr), + ast::Expr::Tuple(ast::ExprTuple { elts, .. }) + | ast::Expr::List(ast::ExprList { elts, .. }) + | ast::Expr::Set(ast::ExprSet { elts, .. }) => elts.iter().all(is_const_expr), + ast::Expr::Dict(ast::ExprDict { items, .. }) => items.iter().all(|item| { + item.key.as_ref().is_none_or(is_const_expr) && is_const_expr(&item.value) + }), + _ => false, + } + } + + match predicate.node { + PredicateNode::Expression(expression) => { + let parsed = parsed_module(db, expression.file(db)).load(db); + is_const_expr(expression.node_ref(db, &parsed)) + } + _ => false, + } + } } From a0a5213334d13718046a9f7ea2f1af7f64590a7b Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 13 Feb 2026 02:27:24 +0900 Subject: [PATCH 05/69] Revert "further `narrow_by_constraint_inner` optimization" This reverts commit f92287c1e7fe99266f6edfe2ccd373d952ae52dc. --- .../reachability_constraints.rs | 61 ++----------------- 1 file changed, 5 insertions(+), 56 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 1df0f61b9a1072..41b9045ad89971 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -195,9 +195,7 @@ use std::cmp::Ordering; -use ruff_db::parsed::parsed_module; use ruff_index::{Idx, IndexVec}; -use ruff_python_ast as ast; use rustc_hash::FxHashMap; use crate::Db; @@ -885,11 +883,11 @@ impl ReachabilityConstraints { node: predicate.node, is_positive: !predicate.is_positive, }; + let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); - // For predicates that don't narrow this place, try a static truthiness fast-path - // only for expressions that are likely const-expr. - // This avoids expensive analysis on dynamic predicates. - if pos_constraint.is_none() && Self::is_const_expression_predicate(db, predicate) { + // If this predicate does not narrow the current place and we can statically + // determine its truthiness, follow only the reachable branch. + if pos_constraint.is_none() && neg_constraint.is_none() { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { return self.narrow_by_constraint_inner( @@ -904,17 +902,13 @@ impl ReachabilityConstraints { ); } Truthiness::AlwaysFalse => { - let neg_constraint = - infer_narrowing_constraint(db, neg_predicate, place); - let false_accumulated = - accumulate_constraint(db, accumulated, neg_constraint); return self.narrow_by_constraint_inner( db, predicates, node.if_false, base_ty, place, - false_accumulated, + accumulated, memo, truthiness_memo, ); @@ -925,7 +919,6 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); return self.narrow_by_constraint_inner( db, @@ -969,7 +962,6 @@ impl ReachabilityConstraints { ); // False branch: predicate doesn't hold → accumulate negative narrowing - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = self.narrow_by_constraint_inner( db, @@ -1238,47 +1230,4 @@ impl ReachabilityConstraints { memo.insert(predicate, analyzed); analyzed } - - /// Cheap syntactic filter for expression predicates where static truthiness is plausible. - /// - /// This is intentionally conservative and only accepts expressions composed from literals and - /// pure operators. It avoids calling expensive `analyze_single` for dynamic expressions where the - /// result is almost always ambiguous. - fn is_const_expression_predicate(db: &dyn Db, predicate: Predicate<'_>) -> bool { - fn is_const_expr(expr: &ast::Expr) -> bool { - match expr { - ast::Expr::BooleanLiteral(_) - | ast::Expr::NoneLiteral(_) - | ast::Expr::EllipsisLiteral(_) - | ast::Expr::NumberLiteral(_) - | ast::Expr::StringLiteral(_) - | ast::Expr::BytesLiteral(_) => true, - ast::Expr::UnaryOp(ast::ExprUnaryOp { operand, .. }) => is_const_expr(operand), - ast::Expr::BinOp(ast::ExprBinOp { left, right, .. }) => { - is_const_expr(left) && is_const_expr(right) - } - ast::Expr::BoolOp(ast::ExprBoolOp { values, .. }) => { - values.iter().all(is_const_expr) - } - ast::Expr::Compare(ast::ExprCompare { - left, comparators, .. - }) => is_const_expr(left) && comparators.iter().all(is_const_expr), - ast::Expr::Tuple(ast::ExprTuple { elts, .. }) - | ast::Expr::List(ast::ExprList { elts, .. }) - | ast::Expr::Set(ast::ExprSet { elts, .. }) => elts.iter().all(is_const_expr), - ast::Expr::Dict(ast::ExprDict { items, .. }) => items.iter().all(|item| { - item.key.as_ref().is_none_or(is_const_expr) && is_const_expr(&item.value) - }), - _ => false, - } - } - - match predicate.node { - PredicateNode::Expression(expression) => { - let parsed = parsed_module(db, expression.file(db)).load(db); - is_const_expr(expression.node_ref(db, &parsed)) - } - _ => false, - } - } } From 69092526f8dc36543fb74bde9a73a5baa50c5cb3 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 13 Feb 2026 02:31:25 +0900 Subject: [PATCH 06/69] improve constant calculations with `resolve_to_literal` --- .../src/semantic_index/builder.rs | 204 ++++++++++++++++-- 1 file changed, 189 insertions(+), 15 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index e8812eb48c3289..0ae2f276487585 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -827,22 +827,196 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { // be exhaustive. More complex expressions will still evaluate to the // correct value during type-checking. fn resolve_to_literal(node: &ast::Expr) -> Option { - match node { - ast::Expr::BooleanLiteral(ast::ExprBooleanLiteral { value, .. }) => Some(*value), - ast::Expr::Name(ast::ExprName { id, .. }) if id == "TYPE_CHECKING" => Some(true), - ast::Expr::NumberLiteral(ast::ExprNumberLiteral { - value: ast::Number::Int(n), - .. - }) => Some(*n != 0), - ast::Expr::EllipsisLiteral(_) => Some(true), - ast::Expr::NoneLiteral(_) => Some(false), - ast::Expr::UnaryOp(ast::ExprUnaryOp { - op: ast::UnaryOp::Not, - operand, - .. - }) => Some(!resolve_to_literal(operand)?), - _ => None, + #[derive(Copy, Clone)] + enum ConstExpr { + Bool(bool), + Int(i64), + None, + Ellipsis, + } + + impl ConstExpr { + fn truthiness(self) -> bool { + match self { + ConstExpr::Bool(value) => value, + ConstExpr::Int(value) => value != 0, + ConstExpr::None => false, + ConstExpr::Ellipsis => true, + } + } + + fn as_int(self) -> Option { + match self { + ConstExpr::Int(value) => Some(value), + ConstExpr::Bool(value) => Some(i64::from(value)), + _ => None, + } + } + } + + fn resolve_const_expr(node: &ast::Expr) -> Option { + match node { + ast::Expr::BooleanLiteral(ast::ExprBooleanLiteral { value, .. }) => { + Some(ConstExpr::Bool(*value)) + } + ast::Expr::Name(ast::ExprName { id, .. }) if id == "TYPE_CHECKING" => { + Some(ConstExpr::Bool(true)) + } + ast::Expr::NumberLiteral(ast::ExprNumberLiteral { + value: ast::Number::Int(n), + .. + }) => n.as_i64().map(ConstExpr::Int), + ast::Expr::EllipsisLiteral(_) => Some(ConstExpr::Ellipsis), + ast::Expr::NoneLiteral(_) => Some(ConstExpr::None), + ast::Expr::UnaryOp(ast::ExprUnaryOp { op, operand, .. }) => { + let operand = resolve_const_expr(operand)?; + match op { + ast::UnaryOp::Not => Some(ConstExpr::Bool(!operand.truthiness())), + ast::UnaryOp::UAdd => Some(ConstExpr::Int(operand.as_int()?)), + ast::UnaryOp::USub => { + Some(ConstExpr::Int(operand.as_int()?.checked_neg()?)) + } + ast::UnaryOp::Invert => Some(ConstExpr::Int(!operand.as_int()?)), + } + } + ast::Expr::BinOp(ast::ExprBinOp { + left, op, right, .. + }) => { + let left = resolve_const_expr(left)?.as_int()?; + let right = resolve_const_expr(right)?.as_int()?; + let value = match op { + ast::Operator::Add => left.checked_add(right)?, + ast::Operator::Sub => left.checked_sub(right)?, + ast::Operator::Mult => left.checked_mul(right)?, + ast::Operator::FloorDiv => { + if right == 0 { + return None; + } + left.div_euclid(right) + } + ast::Operator::Mod => { + if right == 0 { + return None; + } + left.rem_euclid(right) + } + ast::Operator::BitAnd => left & right, + ast::Operator::BitOr => left | right, + ast::Operator::BitXor => left ^ right, + ast::Operator::LShift => { + let shift = u32::try_from(right).ok()?; + left.checked_shl(shift)? + } + ast::Operator::RShift => { + let shift = u32::try_from(right).ok()?; + left.checked_shr(shift)? + } + ast::Operator::Pow => { + let exp = u32::try_from(right).ok()?; + left.checked_pow(exp)? + } + ast::Operator::Div | ast::Operator::MatMult => return None, + }; + Some(ConstExpr::Int(value)) + } + ast::Expr::BoolOp(ast::ExprBoolOp { op, values, .. }) => { + let value = match op { + ast::BoolOp::And => { + let mut all_true = true; + for expr in values { + if !resolve_const_expr(expr)?.truthiness() { + all_true = false; + break; + } + } + all_true + } + ast::BoolOp::Or => { + let mut any_true = false; + for expr in values { + if resolve_const_expr(expr)?.truthiness() { + any_true = true; + break; + } + } + any_true + } + }; + Some(ConstExpr::Bool(value)) + } + ast::Expr::Compare(ast::ExprCompare { + left, + ops, + comparators, + .. + }) => { + let mut left_value = resolve_const_expr(left)?; + for (op, comparator) in ops.iter().zip(comparators.iter()) { + let right_value = resolve_const_expr(comparator)?; + let eq = |left: ConstExpr, right: ConstExpr| match ( + left.as_int(), + right.as_int(), + ) { + (Some(left), Some(right)) => Some(left == right), + _ => match (left, right) { + (ConstExpr::None, ConstExpr::None) + | (ConstExpr::Ellipsis, ConstExpr::Ellipsis) => Some(true), + (ConstExpr::None | ConstExpr::Ellipsis, _) + | (_, ConstExpr::None | ConstExpr::Ellipsis) => Some(false), + _ => None, + }, + }; + let result = match op { + ast::CmpOp::Eq => eq(left_value, right_value)?, + ast::CmpOp::NotEq => !eq(left_value, right_value)?, + ast::CmpOp::Lt => left_value.as_int()? < right_value.as_int()?, + ast::CmpOp::LtE => left_value.as_int()? <= right_value.as_int()?, + ast::CmpOp::Gt => left_value.as_int()? > right_value.as_int()?, + ast::CmpOp::GtE => left_value.as_int()? >= right_value.as_int()?, + ast::CmpOp::Is => match (left_value, right_value) { + (ConstExpr::None, ConstExpr::None) + | (ConstExpr::Ellipsis, ConstExpr::Ellipsis) + | (ConstExpr::Bool(true), ConstExpr::Bool(true)) + | (ConstExpr::Bool(false), ConstExpr::Bool(false)) => true, + ( + ConstExpr::None | ConstExpr::Ellipsis | ConstExpr::Bool(_), + _, + ) + | ( + _, + ConstExpr::None | ConstExpr::Ellipsis | ConstExpr::Bool(_), + ) => false, + _ => return None, + }, + ast::CmpOp::IsNot => match (left_value, right_value) { + (ConstExpr::None, ConstExpr::None) + | (ConstExpr::Ellipsis, ConstExpr::Ellipsis) + | (ConstExpr::Bool(true), ConstExpr::Bool(true)) + | (ConstExpr::Bool(false), ConstExpr::Bool(false)) => false, + ( + ConstExpr::None | ConstExpr::Ellipsis | ConstExpr::Bool(_), + _, + ) + | ( + _, + ConstExpr::None | ConstExpr::Ellipsis | ConstExpr::Bool(_), + ) => true, + _ => return None, + }, + ast::CmpOp::In | ast::CmpOp::NotIn => return None, + }; + if !result { + return Some(ConstExpr::Bool(false)); + } + left_value = right_value; + } + Some(ConstExpr::Bool(true)) + } + _ => None, + } } + + Some(resolve_const_expr(node)?.truthiness()) } let expression = self.add_standalone_expression(predicate_node); From 0e2ca4543b39918b08571b5378942eea49c32dcd Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 13 Feb 2026 03:19:31 +0900 Subject: [PATCH 07/69] further `narrow_by_constraint_inner` optimization (take 2) --- .../reachability_constraints.rs | 75 ++++++++++--------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 41b9045ad89971..b9c724d4c8afca 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -877,48 +877,14 @@ impl ReachabilityConstraints { }; } - // Check if this predicate narrows the variable we're interested in. - let pos_constraint = infer_narrowing_constraint(db, predicate, place); let neg_predicate = Predicate { node: predicate.node, is_positive: !predicate.is_positive, }; - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); - - // If this predicate does not narrow the current place and we can statically - // determine its truthiness, follow only the reachable branch. - if pos_constraint.is_none() && neg_constraint.is_none() { - match Self::analyze_single_cached(db, predicate, truthiness_memo) { - Truthiness::AlwaysTrue => { - return self.narrow_by_constraint_inner( - db, - predicates, - node.if_true, - base_ty, - place, - accumulated, - memo, - truthiness_memo, - ); - } - Truthiness::AlwaysFalse => { - return self.narrow_by_constraint_inner( - db, - predicates, - node.if_false, - base_ty, - place, - accumulated, - memo, - truthiness_memo, - ); - } - Truthiness::Ambiguous => {} - } - } // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { + let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); return self.narrow_by_constraint_inner( db, @@ -934,6 +900,7 @@ impl ReachabilityConstraints { // If the false branch is statically unreachable, skip it entirely. if node.if_false == ALWAYS_FALSE { + let pos_constraint = infer_narrowing_constraint(db, predicate, place); let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); return self.narrow_by_constraint_inner( db, @@ -947,6 +914,43 @@ impl ReachabilityConstraints { ); } + // If the predicate can't narrow the true branch for this place and we can + // statically prove the predicate value, follow only the reachable branch. + let pos_constraint = infer_narrowing_constraint(db, predicate, place); + if pos_constraint.is_none() { + match Self::analyze_single_cached(db, predicate, truthiness_memo) { + Truthiness::AlwaysTrue => { + return self.narrow_by_constraint_inner( + db, + predicates, + node.if_true, + base_ty, + place, + accumulated, + memo, + truthiness_memo, + ); + } + Truthiness::AlwaysFalse => { + let neg_constraint = + infer_narrowing_constraint(db, neg_predicate, place); + let false_accumulated = + accumulate_constraint(db, accumulated, neg_constraint); + return self.narrow_by_constraint_inner( + db, + predicates, + node.if_false, + base_ty, + place, + false_accumulated, + memo, + truthiness_memo, + ); + } + Truthiness::Ambiguous => {} + } + } + // True branch: predicate holds → accumulate positive narrowing let true_accumulated = accumulate_constraint(db, accumulated.clone(), pos_constraint); @@ -962,6 +966,7 @@ impl ReachabilityConstraints { ); // False branch: predicate doesn't hold → accumulate negative narrowing + let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = self.narrow_by_constraint_inner( db, From ae801f77ea136061f12ca88834bf0a0ecbaad865 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 13 Feb 2026 13:31:48 +0900 Subject: [PATCH 08/69] Revert "improve constant calculations with `resolve_to_literal`" This reverts commit 69092526f8dc36543fb74bde9a73a5baa50c5cb3. --- .../src/semantic_index/builder.rs | 204 ++---------------- 1 file changed, 15 insertions(+), 189 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index 0ae2f276487585..e8812eb48c3289 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -827,196 +827,22 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { // be exhaustive. More complex expressions will still evaluate to the // correct value during type-checking. fn resolve_to_literal(node: &ast::Expr) -> Option { - #[derive(Copy, Clone)] - enum ConstExpr { - Bool(bool), - Int(i64), - None, - Ellipsis, - } - - impl ConstExpr { - fn truthiness(self) -> bool { - match self { - ConstExpr::Bool(value) => value, - ConstExpr::Int(value) => value != 0, - ConstExpr::None => false, - ConstExpr::Ellipsis => true, - } - } - - fn as_int(self) -> Option { - match self { - ConstExpr::Int(value) => Some(value), - ConstExpr::Bool(value) => Some(i64::from(value)), - _ => None, - } - } - } - - fn resolve_const_expr(node: &ast::Expr) -> Option { - match node { - ast::Expr::BooleanLiteral(ast::ExprBooleanLiteral { value, .. }) => { - Some(ConstExpr::Bool(*value)) - } - ast::Expr::Name(ast::ExprName { id, .. }) if id == "TYPE_CHECKING" => { - Some(ConstExpr::Bool(true)) - } - ast::Expr::NumberLiteral(ast::ExprNumberLiteral { - value: ast::Number::Int(n), - .. - }) => n.as_i64().map(ConstExpr::Int), - ast::Expr::EllipsisLiteral(_) => Some(ConstExpr::Ellipsis), - ast::Expr::NoneLiteral(_) => Some(ConstExpr::None), - ast::Expr::UnaryOp(ast::ExprUnaryOp { op, operand, .. }) => { - let operand = resolve_const_expr(operand)?; - match op { - ast::UnaryOp::Not => Some(ConstExpr::Bool(!operand.truthiness())), - ast::UnaryOp::UAdd => Some(ConstExpr::Int(operand.as_int()?)), - ast::UnaryOp::USub => { - Some(ConstExpr::Int(operand.as_int()?.checked_neg()?)) - } - ast::UnaryOp::Invert => Some(ConstExpr::Int(!operand.as_int()?)), - } - } - ast::Expr::BinOp(ast::ExprBinOp { - left, op, right, .. - }) => { - let left = resolve_const_expr(left)?.as_int()?; - let right = resolve_const_expr(right)?.as_int()?; - let value = match op { - ast::Operator::Add => left.checked_add(right)?, - ast::Operator::Sub => left.checked_sub(right)?, - ast::Operator::Mult => left.checked_mul(right)?, - ast::Operator::FloorDiv => { - if right == 0 { - return None; - } - left.div_euclid(right) - } - ast::Operator::Mod => { - if right == 0 { - return None; - } - left.rem_euclid(right) - } - ast::Operator::BitAnd => left & right, - ast::Operator::BitOr => left | right, - ast::Operator::BitXor => left ^ right, - ast::Operator::LShift => { - let shift = u32::try_from(right).ok()?; - left.checked_shl(shift)? - } - ast::Operator::RShift => { - let shift = u32::try_from(right).ok()?; - left.checked_shr(shift)? - } - ast::Operator::Pow => { - let exp = u32::try_from(right).ok()?; - left.checked_pow(exp)? - } - ast::Operator::Div | ast::Operator::MatMult => return None, - }; - Some(ConstExpr::Int(value)) - } - ast::Expr::BoolOp(ast::ExprBoolOp { op, values, .. }) => { - let value = match op { - ast::BoolOp::And => { - let mut all_true = true; - for expr in values { - if !resolve_const_expr(expr)?.truthiness() { - all_true = false; - break; - } - } - all_true - } - ast::BoolOp::Or => { - let mut any_true = false; - for expr in values { - if resolve_const_expr(expr)?.truthiness() { - any_true = true; - break; - } - } - any_true - } - }; - Some(ConstExpr::Bool(value)) - } - ast::Expr::Compare(ast::ExprCompare { - left, - ops, - comparators, - .. - }) => { - let mut left_value = resolve_const_expr(left)?; - for (op, comparator) in ops.iter().zip(comparators.iter()) { - let right_value = resolve_const_expr(comparator)?; - let eq = |left: ConstExpr, right: ConstExpr| match ( - left.as_int(), - right.as_int(), - ) { - (Some(left), Some(right)) => Some(left == right), - _ => match (left, right) { - (ConstExpr::None, ConstExpr::None) - | (ConstExpr::Ellipsis, ConstExpr::Ellipsis) => Some(true), - (ConstExpr::None | ConstExpr::Ellipsis, _) - | (_, ConstExpr::None | ConstExpr::Ellipsis) => Some(false), - _ => None, - }, - }; - let result = match op { - ast::CmpOp::Eq => eq(left_value, right_value)?, - ast::CmpOp::NotEq => !eq(left_value, right_value)?, - ast::CmpOp::Lt => left_value.as_int()? < right_value.as_int()?, - ast::CmpOp::LtE => left_value.as_int()? <= right_value.as_int()?, - ast::CmpOp::Gt => left_value.as_int()? > right_value.as_int()?, - ast::CmpOp::GtE => left_value.as_int()? >= right_value.as_int()?, - ast::CmpOp::Is => match (left_value, right_value) { - (ConstExpr::None, ConstExpr::None) - | (ConstExpr::Ellipsis, ConstExpr::Ellipsis) - | (ConstExpr::Bool(true), ConstExpr::Bool(true)) - | (ConstExpr::Bool(false), ConstExpr::Bool(false)) => true, - ( - ConstExpr::None | ConstExpr::Ellipsis | ConstExpr::Bool(_), - _, - ) - | ( - _, - ConstExpr::None | ConstExpr::Ellipsis | ConstExpr::Bool(_), - ) => false, - _ => return None, - }, - ast::CmpOp::IsNot => match (left_value, right_value) { - (ConstExpr::None, ConstExpr::None) - | (ConstExpr::Ellipsis, ConstExpr::Ellipsis) - | (ConstExpr::Bool(true), ConstExpr::Bool(true)) - | (ConstExpr::Bool(false), ConstExpr::Bool(false)) => false, - ( - ConstExpr::None | ConstExpr::Ellipsis | ConstExpr::Bool(_), - _, - ) - | ( - _, - ConstExpr::None | ConstExpr::Ellipsis | ConstExpr::Bool(_), - ) => true, - _ => return None, - }, - ast::CmpOp::In | ast::CmpOp::NotIn => return None, - }; - if !result { - return Some(ConstExpr::Bool(false)); - } - left_value = right_value; - } - Some(ConstExpr::Bool(true)) - } - _ => None, - } + match node { + ast::Expr::BooleanLiteral(ast::ExprBooleanLiteral { value, .. }) => Some(*value), + ast::Expr::Name(ast::ExprName { id, .. }) if id == "TYPE_CHECKING" => Some(true), + ast::Expr::NumberLiteral(ast::ExprNumberLiteral { + value: ast::Number::Int(n), + .. + }) => Some(*n != 0), + ast::Expr::EllipsisLiteral(_) => Some(true), + ast::Expr::NoneLiteral(_) => Some(false), + ast::Expr::UnaryOp(ast::ExprUnaryOp { + op: ast::UnaryOp::Not, + operand, + .. + }) => Some(!resolve_to_literal(operand)?), + _ => None, } - - Some(resolve_const_expr(node)?.truthiness()) } let expression = self.add_standalone_expression(predicate_node); From 8369501d696ae54f6bcfde446beac67ad2f125c5 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 13 Feb 2026 13:35:11 +0900 Subject: [PATCH 09/69] Revert "further `narrow_by_constraint_inner` optimization (take 2)" This reverts commit 0e2ca4543b39918b08571b5378942eea49c32dcd. --- .../reachability_constraints.rs | 75 +++++++++---------- 1 file changed, 35 insertions(+), 40 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index b9c724d4c8afca..41b9045ad89971 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -877,14 +877,48 @@ impl ReachabilityConstraints { }; } + // Check if this predicate narrows the variable we're interested in. + let pos_constraint = infer_narrowing_constraint(db, predicate, place); let neg_predicate = Predicate { node: predicate.node, is_positive: !predicate.is_positive, }; + let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); + + // If this predicate does not narrow the current place and we can statically + // determine its truthiness, follow only the reachable branch. + if pos_constraint.is_none() && neg_constraint.is_none() { + match Self::analyze_single_cached(db, predicate, truthiness_memo) { + Truthiness::AlwaysTrue => { + return self.narrow_by_constraint_inner( + db, + predicates, + node.if_true, + base_ty, + place, + accumulated, + memo, + truthiness_memo, + ); + } + Truthiness::AlwaysFalse => { + return self.narrow_by_constraint_inner( + db, + predicates, + node.if_false, + base_ty, + place, + accumulated, + memo, + truthiness_memo, + ); + } + Truthiness::Ambiguous => {} + } + } // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); return self.narrow_by_constraint_inner( db, @@ -900,7 +934,6 @@ impl ReachabilityConstraints { // If the false branch is statically unreachable, skip it entirely. if node.if_false == ALWAYS_FALSE { - let pos_constraint = infer_narrowing_constraint(db, predicate, place); let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); return self.narrow_by_constraint_inner( db, @@ -914,43 +947,6 @@ impl ReachabilityConstraints { ); } - // If the predicate can't narrow the true branch for this place and we can - // statically prove the predicate value, follow only the reachable branch. - let pos_constraint = infer_narrowing_constraint(db, predicate, place); - if pos_constraint.is_none() { - match Self::analyze_single_cached(db, predicate, truthiness_memo) { - Truthiness::AlwaysTrue => { - return self.narrow_by_constraint_inner( - db, - predicates, - node.if_true, - base_ty, - place, - accumulated, - memo, - truthiness_memo, - ); - } - Truthiness::AlwaysFalse => { - let neg_constraint = - infer_narrowing_constraint(db, neg_predicate, place); - let false_accumulated = - accumulate_constraint(db, accumulated, neg_constraint); - return self.narrow_by_constraint_inner( - db, - predicates, - node.if_false, - base_ty, - place, - false_accumulated, - memo, - truthiness_memo, - ); - } - Truthiness::Ambiguous => {} - } - } - // True branch: predicate holds → accumulate positive narrowing let true_accumulated = accumulate_constraint(db, accumulated.clone(), pos_constraint); @@ -966,7 +962,6 @@ impl ReachabilityConstraints { ); // False branch: predicate doesn't hold → accumulate negative narrowing - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = self.narrow_by_constraint_inner( db, From b2e63ce1acfd6529161678b8e6070d823793027f Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 14 Feb 2026 01:08:56 +0900 Subject: [PATCH 10/69] Reapply "improve constant calculations with `resolve_to_literal`" --- .../mdtest/narrow/post_if_statement.md | 20 ++ .../src/semantic_index/builder.rs | 204 ++++++++++++++++-- 2 files changed, 209 insertions(+), 15 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md index 4dcee3f4d4b396..d897e7836220b0 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md @@ -180,6 +180,8 @@ def _(x: int | None): ``` ```py +from typing import Final + def _(x: int | None): if 1 + 1 == 2: if x is None: @@ -187,6 +189,17 @@ def _(x: int | None): reveal_type(x) # revealed: int reveal_type(x) # revealed: int + +# non-constant but always-true condition +needs_inference: Final = True + +def _(x: int | None): + if needs_inference: + if x is None: + return + reveal_type(x) # revealed: int + + reveal_type(x) # revealed: int ``` This also works when the always-true condition is nested inside a narrowing branch: @@ -198,6 +211,13 @@ def _(x: int | None): return reveal_type(x) # revealed: int + +def _(x: int | None): + if x is None: + if needs_inference: + return + + reveal_type(x) # revealed: int ``` ## Narrowing from `assert` should not affect reassigned variables diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index e8812eb48c3289..0ae2f276487585 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -827,22 +827,196 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { // be exhaustive. More complex expressions will still evaluate to the // correct value during type-checking. fn resolve_to_literal(node: &ast::Expr) -> Option { - match node { - ast::Expr::BooleanLiteral(ast::ExprBooleanLiteral { value, .. }) => Some(*value), - ast::Expr::Name(ast::ExprName { id, .. }) if id == "TYPE_CHECKING" => Some(true), - ast::Expr::NumberLiteral(ast::ExprNumberLiteral { - value: ast::Number::Int(n), - .. - }) => Some(*n != 0), - ast::Expr::EllipsisLiteral(_) => Some(true), - ast::Expr::NoneLiteral(_) => Some(false), - ast::Expr::UnaryOp(ast::ExprUnaryOp { - op: ast::UnaryOp::Not, - operand, - .. - }) => Some(!resolve_to_literal(operand)?), - _ => None, + #[derive(Copy, Clone)] + enum ConstExpr { + Bool(bool), + Int(i64), + None, + Ellipsis, + } + + impl ConstExpr { + fn truthiness(self) -> bool { + match self { + ConstExpr::Bool(value) => value, + ConstExpr::Int(value) => value != 0, + ConstExpr::None => false, + ConstExpr::Ellipsis => true, + } + } + + fn as_int(self) -> Option { + match self { + ConstExpr::Int(value) => Some(value), + ConstExpr::Bool(value) => Some(i64::from(value)), + _ => None, + } + } + } + + fn resolve_const_expr(node: &ast::Expr) -> Option { + match node { + ast::Expr::BooleanLiteral(ast::ExprBooleanLiteral { value, .. }) => { + Some(ConstExpr::Bool(*value)) + } + ast::Expr::Name(ast::ExprName { id, .. }) if id == "TYPE_CHECKING" => { + Some(ConstExpr::Bool(true)) + } + ast::Expr::NumberLiteral(ast::ExprNumberLiteral { + value: ast::Number::Int(n), + .. + }) => n.as_i64().map(ConstExpr::Int), + ast::Expr::EllipsisLiteral(_) => Some(ConstExpr::Ellipsis), + ast::Expr::NoneLiteral(_) => Some(ConstExpr::None), + ast::Expr::UnaryOp(ast::ExprUnaryOp { op, operand, .. }) => { + let operand = resolve_const_expr(operand)?; + match op { + ast::UnaryOp::Not => Some(ConstExpr::Bool(!operand.truthiness())), + ast::UnaryOp::UAdd => Some(ConstExpr::Int(operand.as_int()?)), + ast::UnaryOp::USub => { + Some(ConstExpr::Int(operand.as_int()?.checked_neg()?)) + } + ast::UnaryOp::Invert => Some(ConstExpr::Int(!operand.as_int()?)), + } + } + ast::Expr::BinOp(ast::ExprBinOp { + left, op, right, .. + }) => { + let left = resolve_const_expr(left)?.as_int()?; + let right = resolve_const_expr(right)?.as_int()?; + let value = match op { + ast::Operator::Add => left.checked_add(right)?, + ast::Operator::Sub => left.checked_sub(right)?, + ast::Operator::Mult => left.checked_mul(right)?, + ast::Operator::FloorDiv => { + if right == 0 { + return None; + } + left.div_euclid(right) + } + ast::Operator::Mod => { + if right == 0 { + return None; + } + left.rem_euclid(right) + } + ast::Operator::BitAnd => left & right, + ast::Operator::BitOr => left | right, + ast::Operator::BitXor => left ^ right, + ast::Operator::LShift => { + let shift = u32::try_from(right).ok()?; + left.checked_shl(shift)? + } + ast::Operator::RShift => { + let shift = u32::try_from(right).ok()?; + left.checked_shr(shift)? + } + ast::Operator::Pow => { + let exp = u32::try_from(right).ok()?; + left.checked_pow(exp)? + } + ast::Operator::Div | ast::Operator::MatMult => return None, + }; + Some(ConstExpr::Int(value)) + } + ast::Expr::BoolOp(ast::ExprBoolOp { op, values, .. }) => { + let value = match op { + ast::BoolOp::And => { + let mut all_true = true; + for expr in values { + if !resolve_const_expr(expr)?.truthiness() { + all_true = false; + break; + } + } + all_true + } + ast::BoolOp::Or => { + let mut any_true = false; + for expr in values { + if resolve_const_expr(expr)?.truthiness() { + any_true = true; + break; + } + } + any_true + } + }; + Some(ConstExpr::Bool(value)) + } + ast::Expr::Compare(ast::ExprCompare { + left, + ops, + comparators, + .. + }) => { + let mut left_value = resolve_const_expr(left)?; + for (op, comparator) in ops.iter().zip(comparators.iter()) { + let right_value = resolve_const_expr(comparator)?; + let eq = |left: ConstExpr, right: ConstExpr| match ( + left.as_int(), + right.as_int(), + ) { + (Some(left), Some(right)) => Some(left == right), + _ => match (left, right) { + (ConstExpr::None, ConstExpr::None) + | (ConstExpr::Ellipsis, ConstExpr::Ellipsis) => Some(true), + (ConstExpr::None | ConstExpr::Ellipsis, _) + | (_, ConstExpr::None | ConstExpr::Ellipsis) => Some(false), + _ => None, + }, + }; + let result = match op { + ast::CmpOp::Eq => eq(left_value, right_value)?, + ast::CmpOp::NotEq => !eq(left_value, right_value)?, + ast::CmpOp::Lt => left_value.as_int()? < right_value.as_int()?, + ast::CmpOp::LtE => left_value.as_int()? <= right_value.as_int()?, + ast::CmpOp::Gt => left_value.as_int()? > right_value.as_int()?, + ast::CmpOp::GtE => left_value.as_int()? >= right_value.as_int()?, + ast::CmpOp::Is => match (left_value, right_value) { + (ConstExpr::None, ConstExpr::None) + | (ConstExpr::Ellipsis, ConstExpr::Ellipsis) + | (ConstExpr::Bool(true), ConstExpr::Bool(true)) + | (ConstExpr::Bool(false), ConstExpr::Bool(false)) => true, + ( + ConstExpr::None | ConstExpr::Ellipsis | ConstExpr::Bool(_), + _, + ) + | ( + _, + ConstExpr::None | ConstExpr::Ellipsis | ConstExpr::Bool(_), + ) => false, + _ => return None, + }, + ast::CmpOp::IsNot => match (left_value, right_value) { + (ConstExpr::None, ConstExpr::None) + | (ConstExpr::Ellipsis, ConstExpr::Ellipsis) + | (ConstExpr::Bool(true), ConstExpr::Bool(true)) + | (ConstExpr::Bool(false), ConstExpr::Bool(false)) => false, + ( + ConstExpr::None | ConstExpr::Ellipsis | ConstExpr::Bool(_), + _, + ) + | ( + _, + ConstExpr::None | ConstExpr::Ellipsis | ConstExpr::Bool(_), + ) => true, + _ => return None, + }, + ast::CmpOp::In | ast::CmpOp::NotIn => return None, + }; + if !result { + return Some(ConstExpr::Bool(false)); + } + left_value = right_value; + } + Some(ConstExpr::Bool(true)) + } + _ => None, + } } + + Some(resolve_const_expr(node)?.truthiness()) } let expression = self.add_standalone_expression(predicate_node); From 946ea4224cd6716abeeaed4516508cfe7ac8d556 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 14 Feb 2026 01:18:57 +0900 Subject: [PATCH 11/69] Update place_state.rs --- .../src/semantic_index/use_def/place_state.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index 9e31fc930c1ad6..30a8e924c800cc 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -657,6 +657,7 @@ mod tests { ScopedReachabilityConstraintId::ALWAYS_FALSE, false, true, + PreviousDefinitions::AreShadowed, ); let mut sym4b = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); @@ -665,6 +666,7 @@ mod tests { ScopedReachabilityConstraintId::ALWAYS_TRUE, false, true, + PreviousDefinitions::AreShadowed, ); let atom4 = reachability_constraints.add_atom(ScopedPredicateId::new(4)); sym4b.record_narrowing_constraint(&mut reachability_constraints, atom4); From b45d185320a4bb341532f6e486a3305fe283a675 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 14 Feb 2026 15:21:54 +0900 Subject: [PATCH 12/69] `narrow_by_constraint` optimization (take 3) --- .../src/semantic_index/reachability_constraints.rs | 8 ++++++-- .../src/semantic_index/use_def/place_state.rs | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 8a2de2aaa3a4a2..101f70b0f60c9b 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -883,11 +883,13 @@ impl ReachabilityConstraints { node: predicate.node, is_positive: !predicate.is_positive, }; - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); // If this predicate does not narrow the current place and we can statically // determine its truthiness, follow only the reachable branch. - if pos_constraint.is_none() && neg_constraint.is_none() { + if pos_constraint.is_none() + // Defer inference for `neg_predicate` whenever possible. + && infer_narrowing_constraint(db, neg_predicate, place).is_none() + { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { return self.narrow_by_constraint_inner( @@ -919,6 +921,7 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { + let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); return self.narrow_by_constraint_inner( db, @@ -962,6 +965,7 @@ impl ReachabilityConstraints { ); // False branch: predicate doesn't hold → accumulate negative narrowing + let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = self.narrow_by_constraint_inner( db, diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index 30a8e924c800cc..770d37096007fb 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -342,6 +342,7 @@ impl Bindings { == ScopedNarrowingConstraint::ALWAYS_TRUE && b.narrowing_constraint == ScopedNarrowingConstraint::ALWAYS_TRUE { + // short-circuit: if both sides are ALWAYS_TRUE, the result is ALWAYS_TRUE without needing to create a new TDD node. ScopedNarrowingConstraint::ALWAYS_TRUE } else { // A branch contributes narrowing only when it is reachable. From f10df50f35d1a584e178368924606faacb125de4 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 14 Feb 2026 15:27:10 +0900 Subject: [PATCH 13/69] Revert "`narrow_by_constraint` optimization (take 3)" This reverts commit b45d185320a4bb341532f6e486a3305fe283a675. --- .../src/semantic_index/reachability_constraints.rs | 8 ++------ .../src/semantic_index/use_def/place_state.rs | 1 - 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 101f70b0f60c9b..8a2de2aaa3a4a2 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -883,13 +883,11 @@ impl ReachabilityConstraints { node: predicate.node, is_positive: !predicate.is_positive, }; + let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); // If this predicate does not narrow the current place and we can statically // determine its truthiness, follow only the reachable branch. - if pos_constraint.is_none() - // Defer inference for `neg_predicate` whenever possible. - && infer_narrowing_constraint(db, neg_predicate, place).is_none() - { + if pos_constraint.is_none() && neg_constraint.is_none() { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { return self.narrow_by_constraint_inner( @@ -921,7 +919,6 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); return self.narrow_by_constraint_inner( db, @@ -965,7 +962,6 @@ impl ReachabilityConstraints { ); // False branch: predicate doesn't hold → accumulate negative narrowing - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = self.narrow_by_constraint_inner( db, diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index 770d37096007fb..30a8e924c800cc 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -342,7 +342,6 @@ impl Bindings { == ScopedNarrowingConstraint::ALWAYS_TRUE && b.narrowing_constraint == ScopedNarrowingConstraint::ALWAYS_TRUE { - // short-circuit: if both sides are ALWAYS_TRUE, the result is ALWAYS_TRUE without needing to create a new TDD node. ScopedNarrowingConstraint::ALWAYS_TRUE } else { // A branch contributes narrowing only when it is reachable. From de6d3c1805fbb7e32d41815852d08f7d18be7e0b Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 14 Feb 2026 16:14:05 +0900 Subject: [PATCH 14/69] Reapply "`narrow_by_constraint` optimization (take 3)" This reverts commit f10df50f35d1a584e178368924606faacb125de4. --- .../src/semantic_index/reachability_constraints.rs | 8 ++++++-- .../src/semantic_index/use_def/place_state.rs | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 8a2de2aaa3a4a2..101f70b0f60c9b 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -883,11 +883,13 @@ impl ReachabilityConstraints { node: predicate.node, is_positive: !predicate.is_positive, }; - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); // If this predicate does not narrow the current place and we can statically // determine its truthiness, follow only the reachable branch. - if pos_constraint.is_none() && neg_constraint.is_none() { + if pos_constraint.is_none() + // Defer inference for `neg_predicate` whenever possible. + && infer_narrowing_constraint(db, neg_predicate, place).is_none() + { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { return self.narrow_by_constraint_inner( @@ -919,6 +921,7 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { + let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); return self.narrow_by_constraint_inner( db, @@ -962,6 +965,7 @@ impl ReachabilityConstraints { ); // False branch: predicate doesn't hold → accumulate negative narrowing + let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = self.narrow_by_constraint_inner( db, diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index 30a8e924c800cc..770d37096007fb 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -342,6 +342,7 @@ impl Bindings { == ScopedNarrowingConstraint::ALWAYS_TRUE && b.narrowing_constraint == ScopedNarrowingConstraint::ALWAYS_TRUE { + // short-circuit: if both sides are ALWAYS_TRUE, the result is ALWAYS_TRUE without needing to create a new TDD node. ScopedNarrowingConstraint::ALWAYS_TRUE } else { // A branch contributes narrowing only when it is reachable. From 4eb12c812f9d2cc6ab2251f98582b1085587e474 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 14 Feb 2026 19:47:41 +0900 Subject: [PATCH 15/69] add `PlaceVersion` to prevent the old shadowed narrowing constraint from being applied --- .../resources/mdtest/loops/while_loop.md | 42 ++++- .../src/semantic_index/builder.rs | 73 +++++++- .../reachability_constraints.rs | 159 ++++++++---------- .../src/semantic_index/use_def.rs | 76 ++++++++- .../src/semantic_index/use_def/place_state.rs | 39 +++++ 5 files changed, 284 insertions(+), 105 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/loops/while_loop.md b/crates/ty_python_semantic/resources/mdtest/loops/while_loop.md index a14f891d932f27..ad85d5f231dbd5 100644 --- a/crates/ty_python_semantic/resources/mdtest/loops/while_loop.md +++ b/crates/ty_python_semantic/resources/mdtest/loops/while_loop.md @@ -247,21 +247,45 @@ Here the loop condition forces `x` to be `False` at loop exit, because there is def random() -> bool: return True -x = random() -reveal_type(x) # revealed: bool -while x: - pass -reveal_type(x) # revealed: Literal[False] +def _(x: bool): + while x: + pass + reveal_type(x) # revealed: Literal[False] ``` However, we can't narrow `x` like this when there's a `break` in the loop: ```py -x = random() -while x: - if random(): +def _(x: bool): + while x: + if random(): + break + reveal_type(x) # revealed: bool + +def _(x: bool): + while x: + pass + reveal_type(x) # revealed: Literal[False] + + x = random() + while x: + if random(): + break + reveal_type(x) # revealed: bool + +def _(y: int | None): + x = 1 + while True: + if x == 0: + break + + if y is None: + y = 0 + continue + break -reveal_type(x) # revealed: bool + + reveal_type(y) # revealed: int ``` ### Non-static loop conditions diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index 67b6088157a5ba..bdcd224c981cfe 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -68,6 +68,8 @@ struct Loop { break_states: Vec, /// Flow states at each `continue` in the current loop. continue_states: Vec, + /// Places that are bound within this loop body. + bound_places: FxHashSet, } impl Loop { @@ -263,10 +265,12 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { } /// Push a new loop, returning the outer loop, if any. - fn push_loop(&mut self) -> Option { - self.current_scope_info_mut() - .current_loop - .replace(Loop::default()) + fn push_loop(&mut self, bound_places: FxHashSet) -> Option { + self.current_scope_info_mut().current_loop.replace(Loop { + break_states: Vec::default(), + continue_states: Vec::default(), + bound_places, + }) } /// Pop a loop, replacing with the previous saved outer loop, if any. @@ -1136,8 +1140,19 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { predicate: ScopedPredicateId, places: &PossiblyNarrowedPlaces, ) { + let allow_future_versions_for = self + .current_scope_info() + .current_loop + .as_ref() + .map_or_else(FxHashSet::default, |current_loop| { + places + .iter() + .copied() + .filter(|place| current_loop.bound_places.contains(place)) + .collect() + }); self.current_use_def_map_mut() - .record_narrowing_constraint_for_places(predicate, places); + .record_narrowing_constraint_for_places(predicate, places, &allow_future_versions_for); } /// Adds and records a narrowing constraint for only the places that could possibly be narrowed. @@ -1149,9 +1164,24 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { predicate: PredicateOrLiteral<'db>, ) -> ScopedPredicateId { let possibly_narrowed = self.compute_possibly_narrowed_places(&predicate); + let allow_future_versions_for = self + .current_scope_info() + .current_loop + .as_ref() + .map_or_else(FxHashSet::default, |current_loop| { + possibly_narrowed + .iter() + .copied() + .filter(|place| current_loop.bound_places.contains(place)) + .collect() + }); let use_def = self.current_use_def_map_mut(); let predicate_id = use_def.add_predicate(predicate); - use_def.record_narrowing_constraint_for_places(predicate_id, &possibly_narrowed); + use_def.record_narrowing_constraint_for_places( + predicate_id, + &possibly_narrowed, + &allow_future_versions_for, + ); predicate_id } @@ -1203,8 +1233,23 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { predicate_id: ScopedPredicateId, ) { let possibly_narrowed = self.compute_possibly_narrowed_places(&predicate); + let allow_future_versions_for = self + .current_scope_info() + .current_loop + .as_ref() + .map_or_else(FxHashSet::default, |current_loop| { + possibly_narrowed + .iter() + .copied() + .filter(|place| current_loop.bound_places.contains(place)) + .collect() + }); self.current_use_def_map_mut() - .record_negated_narrowing_constraint_for_places(predicate_id, &possibly_narrowed); + .record_negated_narrowing_constraint_for_places( + predicate_id, + &possibly_narrowed, + &allow_future_versions_for, + ); } /// Records that all remaining statements in the current block are unreachable. @@ -2371,7 +2416,12 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { let (predicate, predicate_id) = self.record_expression_narrowing_constraint(test); self.record_reachability_constraint_id(predicate_id); - let outer_loop = self.push_loop(); + let loop_bound_places = maybe_loop_header_info + .as_ref() + .map_or_else(FxHashSet::default, |(_, bound_place_ids)| { + bound_place_ids.clone() + }); + let outer_loop = self.push_loop(loop_bound_places); self.visit_body(body); let this_loop = self.pop_loop(outer_loop); @@ -2479,7 +2529,12 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { self.add_unpackable_assignment(&Unpackable::For(for_stmt), target, iter_expr); - let outer_loop = self.push_loop(); + let loop_bound_places = maybe_loop_header_info + .as_ref() + .map_or_else(FxHashSet::default, |(_, bound_place_ids)| { + bound_place_ids.clone() + }); + let outer_loop = self.push_loop(loop_bound_places); self.visit_body(body); let this_loop = self.pop_loop(outer_loop); diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 101f70b0f60c9b..5fe26d0720baac 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -208,6 +208,7 @@ use crate::semantic_index::predicate::{ CallableAndCallExpr, PatternPredicate, PatternPredicateKind, Predicate, PredicateNode, Predicates, ScopedPredicateId, }; +use crate::semantic_index::use_def::{PlaceVersion, PredicatePlaceVersions}; use crate::types::{ CallableTypes, IntersectionBuilder, NarrowingConstraint, Truthiness, Type, TypeContext, UnionBuilder, UnionType, infer_expression_type, infer_narrowing_constraint, @@ -783,22 +784,27 @@ impl ReachabilityConstraints { /// - `ALWAYS_FALSE`: this path is impossible → Never /// /// The final result is the union of all path results. + #[expect(clippy::too_many_arguments)] pub(crate) fn narrow_by_constraint<'db>( &self, db: &'db dyn Db, predicates: &Predicates<'db>, + predicate_place_versions: &PredicatePlaceVersions, id: ScopedReachabilityConstraintId, base_ty: Type<'db>, place: ScopedPlaceId, + binding_place_version: Option, ) -> Type<'db> { let mut memo = FxHashMap::default(); let mut truthiness_memo = FxHashMap::default(); self.narrow_by_constraint_inner( db, predicates, + predicate_place_versions, id, base_ty, place, + binding_place_version, None, &mut memo, &mut truthiness_memo, @@ -811,9 +817,11 @@ impl ReachabilityConstraints { &self, db: &'db dyn Db, predicates: &Predicates<'db>, + predicate_place_versions: &PredicatePlaceVersions, id: ScopedReachabilityConstraintId, base_ty: Type<'db>, place: ScopedPlaceId, + binding_place_version: Option, accumulated: Option>, memo: &mut FxHashMap< ( @@ -843,6 +851,22 @@ impl ReachabilityConstraints { _ => { let node = self.get_interior_node(id); let predicate = predicates[node.atom]; + macro_rules! narrow { + ($next_id:expr, $next_accumulated:expr) => { + self.narrow_by_constraint_inner( + db, + predicates, + predicate_place_versions, + $next_id, + base_ty, + place, + binding_place_version, + $next_accumulated, + memo, + truthiness_memo, + ) + }; + } // `ReturnsNever` predicates don't narrow any variable; they only // affect reachability. Evaluate the predicate to determine which @@ -851,26 +875,8 @@ impl ReachabilityConstraints { // never `Ambiguous`. if matches!(predicate.node, PredicateNode::ReturnsNever(_)) { return match Self::analyze_single_cached(db, predicate, truthiness_memo) { - Truthiness::AlwaysTrue => self.narrow_by_constraint_inner( - db, - predicates, - node.if_true, - base_ty, - place, - accumulated, - memo, - truthiness_memo, - ), - Truthiness::AlwaysFalse => self.narrow_by_constraint_inner( - db, - predicates, - node.if_false, - base_ty, - place, - accumulated, - memo, - truthiness_memo, - ), + Truthiness::AlwaysTrue => narrow!(node.if_true, accumulated), + Truthiness::AlwaysFalse => narrow!(node.if_false, accumulated), Truthiness::Ambiguous => { unreachable!("ReturnsNever predicates should never be Ambiguous") } @@ -883,37 +889,36 @@ impl ReachabilityConstraints { node: predicate.node, is_positive: !predicate.is_positive, }; + let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); + + // Only gate by place version if this predicate can narrow the current place. + // Predicates unrelated to `place` are still useful for reachability pruning. + let has_narrowing_constraints = + pos_constraint.is_some() || neg_constraint.is_some(); + if has_narrowing_constraints + && !Self::predicate_applies_to_place_version( + predicate_place_versions, + node.atom, + place, + binding_place_version, + ) + { + // This narrowing predicate belongs to an older/newer place version and + // must not influence narrowing for the current binding. + let true_ty = narrow!(node.if_true, accumulated.clone()); + let false_ty = narrow!(node.if_false, accumulated); + return UnionType::from_elements(db, [true_ty, false_ty]); + } // If this predicate does not narrow the current place and we can statically // determine its truthiness, follow only the reachable branch. - if pos_constraint.is_none() - // Defer inference for `neg_predicate` whenever possible. - && infer_narrowing_constraint(db, neg_predicate, place).is_none() - { + if !has_narrowing_constraints { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { - return self.narrow_by_constraint_inner( - db, - predicates, - node.if_true, - base_ty, - place, - accumulated, - memo, - truthiness_memo, - ); + return narrow!(node.if_true, accumulated); } Truthiness::AlwaysFalse => { - return self.narrow_by_constraint_inner( - db, - predicates, - node.if_false, - base_ty, - place, - accumulated, - memo, - truthiness_memo, - ); + return narrow!(node.if_false, accumulated); } Truthiness::Ambiguous => {} } @@ -921,62 +926,24 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); - return self.narrow_by_constraint_inner( - db, - predicates, - node.if_false, - base_ty, - place, - false_accumulated, - memo, - truthiness_memo, - ); + return narrow!(node.if_false, false_accumulated); } // If the false branch is statically unreachable, skip it entirely. if node.if_false == ALWAYS_FALSE { let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); - return self.narrow_by_constraint_inner( - db, - predicates, - node.if_true, - base_ty, - place, - true_accumulated, - memo, - truthiness_memo, - ); + return narrow!(node.if_true, true_accumulated); } // True branch: predicate holds → accumulate positive narrowing let true_accumulated = accumulate_constraint(db, accumulated.clone(), pos_constraint); - let true_ty = self.narrow_by_constraint_inner( - db, - predicates, - node.if_true, - base_ty, - place, - true_accumulated, - memo, - truthiness_memo, - ); + let true_ty = narrow!(node.if_true, true_accumulated); // False branch: predicate doesn't hold → accumulate negative narrowing - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); - let false_ty = self.narrow_by_constraint_inner( - db, - predicates, - node.if_false, - base_ty, - place, - false_accumulated, - memo, - truthiness_memo, - ); + let false_ty = narrow!(node.if_false, false_accumulated); UnionType::from_elements(db, [true_ty, false_ty]) } @@ -986,6 +953,26 @@ impl ReachabilityConstraints { narrowed } + fn predicate_applies_to_place_version( + predicate_place_versions: &PredicatePlaceVersions, + predicate_id: ScopedPredicateId, + place: ScopedPlaceId, + binding_place_version: Option, + ) -> bool { + binding_place_version.is_none_or(|binding_place_version| { + predicate_place_versions + .get(&(predicate_id, place)) + .is_some_and(|info| { + info.versions.contains(&binding_place_version) + || info.allow_future_versions + && info + .versions + .last() + .is_some_and(|max| binding_place_version > *max) + }) + }) + } + /// Analyze the statically known reachability for a given constraint. pub(crate) fn evaluate<'db>( &self, diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index c0597e87a8e51b..6830d183397c60 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -242,6 +242,8 @@ use ruff_index::{IndexVec, newtype_index}; use rustc_hash::FxHashMap; +use rustc_hash::FxHashSet; +use smallvec::SmallVec; use crate::node_key::NodeKey; use crate::place::BoundnessAnalysis; @@ -268,7 +270,16 @@ use crate::types::{PossiblyNarrowedPlaces, Truthiness, Type}; mod place_state; pub(super) use place_state::PreviousDefinitions; -pub(crate) use place_state::{LiveBinding, ScopedDefinitionId}; +pub(crate) use place_state::{LiveBinding, PlaceVersion, ScopedDefinitionId}; + +#[derive(Clone, Debug, Default, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +pub(crate) struct PredicatePlaceVersionInfo { + pub(crate) versions: SmallVec<[PlaceVersion; 2]>, + pub(crate) allow_future_versions: bool, +} + +pub(crate) type PredicatePlaceVersions = + FxHashMap<(ScopedPredicateId, ScopedPlaceId), PredicatePlaceVersionInfo>; /// Applicable definitions and constraints for every use of a name. #[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] @@ -280,6 +291,9 @@ pub(crate) struct UseDefMap<'db> { /// Array of predicates in this scope. predicates: Predicates<'db>, + /// Place versions to which a given predicate occurrence can apply for narrowing. + predicate_place_versions: PredicatePlaceVersions, + /// Array of reachability constraints in this scope. reachability_constraints: ReachabilityConstraints, @@ -373,7 +387,9 @@ impl<'db> UseDefMap<'db> { ApplicableConstraints::UnboundBinding(NarrowingEvaluator { constraint, predicates: &self.predicates, + predicate_place_versions: &self.predicate_place_versions, reachability_constraints: &self.reachability_constraints, + binding_place_version: None, }) } ConstraintKey::NestedScope(nested_scope) => { @@ -413,7 +429,9 @@ impl<'db> UseDefMap<'db> { NarrowingEvaluator { constraint, predicates: &self.predicates, + predicate_place_versions: &self.predicate_place_versions, reachability_constraints: &self.reachability_constraints, + binding_place_version: None, } } @@ -655,6 +673,7 @@ impl<'db> UseDefMap<'db> { BindingWithConstraintsIterator { all_definitions: &self.all_definitions, predicates: &self.predicates, + predicate_place_versions: &self.predicate_place_versions, reachability_constraints: &self.reachability_constraints, boundness_analysis, inner: bindings.iter(), @@ -710,6 +729,7 @@ type EnclosingSnapshots = IndexVec pub(crate) struct BindingWithConstraintsIterator<'map, 'db> { pub(crate) all_definitions: &'map IndexVec>, pub(crate) predicates: &'map Predicates<'db>, + pub(crate) predicate_place_versions: &'map PredicatePlaceVersions, pub(crate) reachability_constraints: &'map ReachabilityConstraints, pub(crate) boundness_analysis: BoundnessAnalysis, inner: LiveBindingsIterator<'map>, @@ -720,6 +740,7 @@ impl<'map, 'db> Iterator for BindingWithConstraintsIterator<'map, 'db> { fn next(&mut self) -> Option { let predicates = self.predicates; + let predicate_place_versions = self.predicate_place_versions; let reachability_constraints = self.reachability_constraints; self.inner @@ -729,7 +750,9 @@ impl<'map, 'db> Iterator for BindingWithConstraintsIterator<'map, 'db> { narrowing_constraint: NarrowingEvaluator { constraint: live_binding.narrowing_constraint, predicates, + predicate_place_versions, reachability_constraints, + binding_place_version: Some(live_binding.place_version), }, reachability_constraint: live_binding.reachability_constraint, }) @@ -747,7 +770,9 @@ pub(crate) struct BindingWithConstraints<'map, 'db> { pub(crate) struct NarrowingEvaluator<'map, 'db> { pub(crate) constraint: ScopedNarrowingConstraint, predicates: &'map Predicates<'db>, + predicate_place_versions: &'map PredicatePlaceVersions, reachability_constraints: &'map ReachabilityConstraints, + binding_place_version: Option, } impl<'db> NarrowingEvaluator<'_, 'db> { @@ -760,9 +785,11 @@ impl<'db> NarrowingEvaluator<'_, 'db> { self.reachability_constraints.narrow_by_constraint( db, self.predicates, + self.predicate_place_versions, self.constraint, base_ty, place, + self.binding_place_version, ) } } @@ -831,6 +858,9 @@ pub(super) struct UseDefMapBuilder<'db> { /// Builder of predicates. pub(super) predicates: PredicatesBuilder<'db>, + /// Place versions to which a given predicate occurrence can apply for narrowing. + predicate_place_versions: PredicatePlaceVersions, + /// Builder of reachability constraints. pub(super) reachability_constraints: ReachabilityConstraintsBuilder, @@ -873,6 +903,7 @@ impl<'db> UseDefMapBuilder<'db> { Self { all_definitions: IndexVec::from_iter([DefinitionState::Undefined]), predicates: PredicatesBuilder::default(), + predicate_place_versions: PredicatePlaceVersions::default(), reachability_constraints: ReachabilityConstraintsBuilder::default(), bindings_by_use: IndexVec::new(), reachability: ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -1001,6 +1032,7 @@ impl<'db> UseDefMapBuilder<'db> { &mut self, predicate: ScopedPredicateId, places: &PossiblyNarrowedPlaces, + allow_future_versions_for: &FxHashSet, ) { if predicate == ScopedPredicateId::ALWAYS_TRUE || predicate == ScopedPredicateId::ALWAYS_FALSE @@ -1009,6 +1041,8 @@ impl<'db> UseDefMapBuilder<'db> { return; } + self.record_predicate_place_versions(predicate, places, allow_future_versions_for); + let atom = self.reachability_constraints.add_atom(predicate); self.record_narrowing_constraint_node_for_places(atom, places); } @@ -1023,6 +1057,7 @@ impl<'db> UseDefMapBuilder<'db> { &mut self, predicate: ScopedPredicateId, places: &PossiblyNarrowedPlaces, + allow_future_versions_for: &FxHashSet, ) { if predicate == ScopedPredicateId::ALWAYS_TRUE || predicate == ScopedPredicateId::ALWAYS_FALSE @@ -1030,11 +1065,48 @@ impl<'db> UseDefMapBuilder<'db> { return; } + self.record_predicate_place_versions(predicate, places, allow_future_versions_for); + let atom = self.reachability_constraints.add_atom(predicate); let negated = self.reachability_constraints.add_not_constraint(atom); self.record_narrowing_constraint_node_for_places(negated, places); } + fn record_predicate_place_versions( + &mut self, + predicate: ScopedPredicateId, + places: &PossiblyNarrowedPlaces, + allow_future_versions_for: &FxHashSet, + ) { + for place in places { + let bindings = match place { + ScopedPlaceId::Symbol(symbol_id) => { + self.symbol_states.get(*symbol_id).map(PlaceState::bindings) + } + ScopedPlaceId::Member(member_id) => { + self.member_states.get(*member_id).map(PlaceState::bindings) + } + }; + let Some(bindings) = bindings else { + continue; + }; + + let entry = self + .predicate_place_versions + .entry((predicate, *place)) + .or_default(); + if allow_future_versions_for.contains(place) { + entry.allow_future_versions = true; + } + for version in bindings.place_versions() { + if !entry.versions.contains(&version) { + entry.versions.push(version); + } + } + entry.versions.sort_unstable(); + } + } + /// Records a TDD narrowing constraint node for the specified places. fn record_narrowing_constraint_node_for_places( &mut self, @@ -1509,6 +1581,7 @@ impl<'db> UseDefMapBuilder<'db> { self.reachable_symbol_definitions.shrink_to_fit(); self.reachable_member_definitions.shrink_to_fit(); self.bindings_by_use.shrink_to_fit(); + self.predicate_place_versions.shrink_to_fit(); self.node_reachability.shrink_to_fit(); self.declarations_by_binding.shrink_to_fit(); self.bindings_by_definition.shrink_to_fit(); @@ -1517,6 +1590,7 @@ impl<'db> UseDefMapBuilder<'db> { UseDefMap { all_definitions: self.all_definitions, predicates: self.predicates.build(), + predicate_place_versions: self.predicate_place_versions, reachability_constraints: self.reachability_constraints.build(), bindings_by_use: self.bindings_by_use, node_reachability: self.node_reachability, diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index 770d37096007fb..d933387266be39 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -69,6 +69,29 @@ impl ScopedDefinitionId { } } +/// A monotonically increasing place generation. +/// +/// The generation increments whenever bindings for a place are shadowed by reassignment. +#[newtype_index] +#[derive(Ord, PartialOrd, salsa::Update, get_size2::GetSize)] +pub(crate) struct PlaceVersion; + +impl Default for PlaceVersion { + fn default() -> Self { + PlaceVersion::from_u32(0) + } +} + +impl PlaceVersion { + pub(crate) fn next(self) -> PlaceVersion { + let next = self + .as_u32() + .checked_add(1) + .expect("PlaceVersion overflowed"); + PlaceVersion::from_u32(next) + } +} + /// Live declarations for a single place at some point in control flow, with their /// corresponding reachability constraints. #[derive(Clone, Debug, Default, PartialEq, Eq, salsa::Update, get_size2::GetSize)] @@ -213,7 +236,10 @@ pub(super) struct Bindings { /// "unbound" binding. unbound_narrowing_constraint: Option, /// A list of live bindings for this place, sorted by their `ScopedDefinitionId` + #[expect(clippy::struct_field_names)] live_bindings: SmallVec<[LiveBinding; 2]>, + /// Latest place version seen for this place. + latest_place_version: PlaceVersion, } impl Bindings { @@ -237,6 +263,7 @@ pub(crate) struct LiveBinding { pub(crate) binding: ScopedDefinitionId, pub(crate) narrowing_constraint: ScopedNarrowingConstraint, pub(crate) reachability_constraint: ScopedReachabilityConstraintId, + pub(crate) place_version: PlaceVersion, } pub(super) type LiveBindingsIterator<'a> = std::slice::Iter<'a, LiveBinding>; @@ -247,10 +274,12 @@ impl Bindings { binding: ScopedDefinitionId::UNBOUND, narrowing_constraint: ScopedNarrowingConstraint::ALWAYS_TRUE, reachability_constraint, + place_version: PlaceVersion::default(), }; Self { unbound_narrowing_constraint: None, live_bindings: smallvec![initial_binding], + latest_place_version: PlaceVersion::default(), } } @@ -272,11 +301,13 @@ impl Bindings { // constraints. if previous_definitions.are_shadowed() { self.live_bindings.clear(); + self.latest_place_version = self.latest_place_version.next(); } self.live_bindings.push(LiveBinding { binding, narrowing_constraint: ScopedNarrowingConstraint::ALWAYS_TRUE, reachability_constraint, + place_version: self.latest_place_version, }); } @@ -309,12 +340,19 @@ impl Bindings { self.live_bindings.iter() } + pub(super) fn place_versions(&self) -> impl Iterator + '_ { + self.live_bindings + .iter() + .map(|binding| binding.place_version) + } + pub(super) fn merge( &mut self, b: Self, reachability_constraints: &mut ReachabilityConstraintsBuilder, ) { let a = std::mem::take(self); + self.latest_place_version = a.latest_place_version.max(b.latest_place_version); if let Some((a, b)) = a .unbound_narrowing_constraint @@ -361,6 +399,7 @@ impl Bindings { binding: a.binding, narrowing_constraint, reachability_constraint, + place_version: a.place_version.max(b.place_version), }); } From 19e63c4f7291a4bf5128799b0ed5542bd5e41318 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sun, 15 Feb 2026 11:19:11 +0900 Subject: [PATCH 16/69] `narrow_by_constraint` optimization using `PlaceVersion` --- .../reachability_constraints.rs | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 5fe26d0720baac..c55ab6d83eb8a9 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -208,7 +208,9 @@ use crate::semantic_index::predicate::{ CallableAndCallExpr, PatternPredicate, PatternPredicateKind, Predicate, PredicateNode, Predicates, ScopedPredicateId, }; -use crate::semantic_index::use_def::{PlaceVersion, PredicatePlaceVersions}; +use crate::semantic_index::use_def::{ + PlaceVersion, PredicatePlaceVersionInfo, PredicatePlaceVersions, +}; use crate::types::{ CallableTypes, IntersectionBuilder, NarrowingConstraint, Truthiness, Type, TypeContext, UnionBuilder, UnionType, infer_expression_type, infer_narrowing_constraint, @@ -884,12 +886,21 @@ impl ReachabilityConstraints { } // Check if this predicate narrows the variable we're interested in. - let pos_constraint = infer_narrowing_constraint(db, predicate, place); let neg_predicate = Predicate { node: predicate.node, is_positive: !predicate.is_positive, }; - let neg_constraint = infer_narrowing_constraint(db, neg_predicate, place); + let place_version_info = predicate_place_versions.get(&(node.atom, place)); + let (pos_constraint, neg_constraint) = if place_version_info.is_some() { + ( + infer_narrowing_constraint(db, predicate, place), + infer_narrowing_constraint(db, neg_predicate, place), + ) + } else { + // No recorded place-version metadata means this predicate cannot narrow + // this place, so skip the expensive narrowing-inference queries. + (None, None) + }; // Only gate by place version if this predicate can narrow the current place. // Predicates unrelated to `place` are still useful for reachability pruning. @@ -897,9 +908,7 @@ impl ReachabilityConstraints { pos_constraint.is_some() || neg_constraint.is_some(); if has_narrowing_constraints && !Self::predicate_applies_to_place_version( - predicate_place_versions, - node.atom, - place, + place_version_info, binding_place_version, ) { @@ -954,22 +963,18 @@ impl ReachabilityConstraints { } fn predicate_applies_to_place_version( - predicate_place_versions: &PredicatePlaceVersions, - predicate_id: ScopedPredicateId, - place: ScopedPlaceId, + place_version_info: Option<&PredicatePlaceVersionInfo>, binding_place_version: Option, ) -> bool { binding_place_version.is_none_or(|binding_place_version| { - predicate_place_versions - .get(&(predicate_id, place)) - .is_some_and(|info| { - info.versions.contains(&binding_place_version) - || info.allow_future_versions - && info - .versions - .last() - .is_some_and(|max| binding_place_version > *max) - }) + place_version_info.is_some_and(|info| { + info.versions.contains(&binding_place_version) + || info.allow_future_versions + && info + .versions + .last() + .is_some_and(|max| binding_place_version > *max) + }) }) } From 50fc1e9060d5628e6db21f29f5bfe2c491ac7367 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sun, 15 Feb 2026 11:49:57 +0900 Subject: [PATCH 17/69] `narrow_by_constraint` optimization using `UnionType::from_elements_without_redundancy_check` --- .../reachability_constraints.rs | 17 ++++++++++++---- crates/ty_python_semantic/src/types.rs | 20 +++++++++++++++++++ .../ty_python_semantic/src/types/builder.rs | 11 +++++++++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index c55ab6d83eb8a9..8e8231682cac59 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -799,7 +799,7 @@ impl ReachabilityConstraints { ) -> Type<'db> { let mut memo = FxHashMap::default(); let mut truthiness_memo = FxHashMap::default(); - self.narrow_by_constraint_inner( + let redundant_union = self.narrow_by_constraint_inner( db, predicates, predicate_place_versions, @@ -810,7 +810,11 @@ impl ReachabilityConstraints { None, &mut memo, &mut truthiness_memo, - ) + ); + UnionBuilder::new(db) + .unpack_aliases(false) + .add(redundant_union) + .build() } /// Inner recursive helper that accumulates narrowing constraints along each TDD path. @@ -916,7 +920,11 @@ impl ReachabilityConstraints { // must not influence narrowing for the current binding. let true_ty = narrow!(node.if_true, accumulated.clone()); let false_ty = narrow!(node.if_false, accumulated); - return UnionType::from_elements(db, [true_ty, false_ty]); + // Optimization: a single redundancy check in `narrow_by_constraint` is sufficient. + return UnionType::from_elements_without_redundancy_check( + db, + [true_ty, false_ty], + ); } // If this predicate does not narrow the current place and we can statically @@ -954,7 +962,8 @@ impl ReachabilityConstraints { let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = narrow!(node.if_false, false_accumulated); - UnionType::from_elements(db, [true_ty, false_ty]) + // Optimization: a single redundancy check in `narrow_by_constraint` is sufficient. + UnionType::from_elements_without_redundancy_check(db, [true_ty, false_ty]) } }; diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 47068e15df746e..353298606ff329 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -12206,6 +12206,26 @@ impl<'db> UnionType<'db> { .build() } + /// Create a union from a list of elements without checking for redundancy. + /// This can be called as an optimization when you are creating a large number of union types but ultimately consolidating them into one place, + /// in which case you only need to do a single redundancy check at the end using `from_elements`. + pub(crate) fn from_elements_without_redundancy_check( + db: &'db dyn Db, + elements: I, + ) -> Type<'db> + where + I: IntoIterator, + T: Into>, + { + elements + .into_iter() + .fold( + UnionBuilder::new(db).check_redundancy(false), + |builder, element| builder.add(element.into()), + ) + .build() + } + fn from_elements_cycle_recovery(db: &'db dyn Db, elements: I) -> Type<'db> where I: IntoIterator, diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 1ba0388b32e6eb..950b17d5515e0c 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -242,11 +242,13 @@ const MAX_NON_RECURSIVE_UNION_LITERALS: usize = 256; /// if reachability analysis etc. fails when analysing these enums. const MAX_NON_RECURSIVE_UNION_ENUM_LITERALS: usize = 8192; +#[allow(clippy::struct_excessive_bools)] pub(crate) struct UnionBuilder<'db> { elements: Vec>, db: &'db dyn Db, unpack_aliases: bool, order_elements: bool, + check_redundancy: bool, /// This is enabled when joining types in a `cycle_recovery` function. /// Since a cycle cannot be created within a `cycle_recovery` function, /// execution of `is_redundant_with` is skipped. @@ -261,6 +263,7 @@ impl<'db> UnionBuilder<'db> { elements: vec![], unpack_aliases: true, order_elements: false, + check_redundancy: true, cycle_recovery: false, recursively_defined: RecursivelyDefined::No, } @@ -276,9 +279,15 @@ impl<'db> UnionBuilder<'db> { self } + pub(crate) fn check_redundancy(mut self, val: bool) -> Self { + self.check_redundancy = val; + self + } + pub(crate) fn cycle_recovery(mut self, val: bool) -> Self { self.cycle_recovery = val; if self.cycle_recovery { + self.check_redundancy = false; self.unpack_aliases = false; } self @@ -622,7 +631,7 @@ impl<'db> UnionBuilder<'db> { // If an alias gets here, it means we aren't unpacking aliases, and we also // shouldn't try to simplify aliases out of the union, because that will require // unpacking them. - let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && !self.cycle_recovery; + let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && self.check_redundancy; let mut ty_negated: Option = None; let mut to_remove = SmallVec::<[usize; 2]>::new(); From cfa026c07e0d193bc22d8cea661d0ab8d898ce6e Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sun, 15 Feb 2026 12:07:46 +0900 Subject: [PATCH 18/69] Revert "`narrow_by_constraint` optimization using `UnionType::from_elements_without_redundancy_check`" This reverts commit 50fc1e9060d5628e6db21f29f5bfe2c491ac7367. --- .../reachability_constraints.rs | 17 ++++------------ crates/ty_python_semantic/src/types.rs | 20 ------------------- .../ty_python_semantic/src/types/builder.rs | 11 +--------- 3 files changed, 5 insertions(+), 43 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 8e8231682cac59..c55ab6d83eb8a9 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -799,7 +799,7 @@ impl ReachabilityConstraints { ) -> Type<'db> { let mut memo = FxHashMap::default(); let mut truthiness_memo = FxHashMap::default(); - let redundant_union = self.narrow_by_constraint_inner( + self.narrow_by_constraint_inner( db, predicates, predicate_place_versions, @@ -810,11 +810,7 @@ impl ReachabilityConstraints { None, &mut memo, &mut truthiness_memo, - ); - UnionBuilder::new(db) - .unpack_aliases(false) - .add(redundant_union) - .build() + ) } /// Inner recursive helper that accumulates narrowing constraints along each TDD path. @@ -920,11 +916,7 @@ impl ReachabilityConstraints { // must not influence narrowing for the current binding. let true_ty = narrow!(node.if_true, accumulated.clone()); let false_ty = narrow!(node.if_false, accumulated); - // Optimization: a single redundancy check in `narrow_by_constraint` is sufficient. - return UnionType::from_elements_without_redundancy_check( - db, - [true_ty, false_ty], - ); + return UnionType::from_elements(db, [true_ty, false_ty]); } // If this predicate does not narrow the current place and we can statically @@ -962,8 +954,7 @@ impl ReachabilityConstraints { let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = narrow!(node.if_false, false_accumulated); - // Optimization: a single redundancy check in `narrow_by_constraint` is sufficient. - UnionType::from_elements_without_redundancy_check(db, [true_ty, false_ty]) + UnionType::from_elements(db, [true_ty, false_ty]) } }; diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 353298606ff329..47068e15df746e 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -12206,26 +12206,6 @@ impl<'db> UnionType<'db> { .build() } - /// Create a union from a list of elements without checking for redundancy. - /// This can be called as an optimization when you are creating a large number of union types but ultimately consolidating them into one place, - /// in which case you only need to do a single redundancy check at the end using `from_elements`. - pub(crate) fn from_elements_without_redundancy_check( - db: &'db dyn Db, - elements: I, - ) -> Type<'db> - where - I: IntoIterator, - T: Into>, - { - elements - .into_iter() - .fold( - UnionBuilder::new(db).check_redundancy(false), - |builder, element| builder.add(element.into()), - ) - .build() - } - fn from_elements_cycle_recovery(db: &'db dyn Db, elements: I) -> Type<'db> where I: IntoIterator, diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 950b17d5515e0c..1ba0388b32e6eb 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -242,13 +242,11 @@ const MAX_NON_RECURSIVE_UNION_LITERALS: usize = 256; /// if reachability analysis etc. fails when analysing these enums. const MAX_NON_RECURSIVE_UNION_ENUM_LITERALS: usize = 8192; -#[allow(clippy::struct_excessive_bools)] pub(crate) struct UnionBuilder<'db> { elements: Vec>, db: &'db dyn Db, unpack_aliases: bool, order_elements: bool, - check_redundancy: bool, /// This is enabled when joining types in a `cycle_recovery` function. /// Since a cycle cannot be created within a `cycle_recovery` function, /// execution of `is_redundant_with` is skipped. @@ -263,7 +261,6 @@ impl<'db> UnionBuilder<'db> { elements: vec![], unpack_aliases: true, order_elements: false, - check_redundancy: true, cycle_recovery: false, recursively_defined: RecursivelyDefined::No, } @@ -279,15 +276,9 @@ impl<'db> UnionBuilder<'db> { self } - pub(crate) fn check_redundancy(mut self, val: bool) -> Self { - self.check_redundancy = val; - self - } - pub(crate) fn cycle_recovery(mut self, val: bool) -> Self { self.cycle_recovery = val; if self.cycle_recovery { - self.check_redundancy = false; self.unpack_aliases = false; } self @@ -631,7 +622,7 @@ impl<'db> UnionBuilder<'db> { // If an alias gets here, it means we aren't unpacking aliases, and we also // shouldn't try to simplify aliases out of the union, because that will require // unpacking them. - let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && self.check_redundancy; + let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && !self.cycle_recovery; let mut ty_negated: Option = None; let mut to_remove = SmallVec::<[usize; 2]>::new(); From 41059e934efb3488b53ff038c2486eabff79b19f Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sun, 15 Feb 2026 13:07:26 +0900 Subject: [PATCH 19/69] optimization in `PredicatePlaceVersionInfo` --- .../src/semantic_index/reachability_constraints.rs | 5 ++--- crates/ty_python_semantic/src/semantic_index/use_def.rs | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index c55ab6d83eb8a9..69d52a3a568be4 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -971,9 +971,8 @@ impl ReachabilityConstraints { info.versions.contains(&binding_place_version) || info.allow_future_versions && info - .versions - .last() - .is_some_and(|max| binding_place_version > *max) + .max_version + .is_some_and(|max| binding_place_version > max) }) }) } diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index 6830d183397c60..34ef58cc9d9855 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -275,6 +275,7 @@ pub(crate) use place_state::{LiveBinding, PlaceVersion, ScopedDefinitionId}; #[derive(Clone, Debug, Default, PartialEq, Eq, salsa::Update, get_size2::GetSize)] pub(crate) struct PredicatePlaceVersionInfo { pub(crate) versions: SmallVec<[PlaceVersion; 2]>, + pub(crate) max_version: Option, pub(crate) allow_future_versions: bool, } @@ -1101,9 +1102,10 @@ impl<'db> UseDefMapBuilder<'db> { for version in bindings.place_versions() { if !entry.versions.contains(&version) { entry.versions.push(version); + entry.max_version = + Some(entry.max_version.map_or(version, |max| max.max(version))); } } - entry.versions.sort_unstable(); } } From cd6c0ef233a74d2dfa0d3126874406c271f78f6b Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sun, 15 Feb 2026 14:12:26 +0900 Subject: [PATCH 20/69] remove `ReturnsNever` special casing --- .../semantic_index/reachability_constraints.rs | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 69d52a3a568be4..1a62fe428dd190 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -870,21 +870,6 @@ impl ReachabilityConstraints { }; } - // `ReturnsNever` predicates don't narrow any variable; they only - // affect reachability. Evaluate the predicate to determine which - // path(s) are reachable, rather than walking both branches. - // `ReturnsNever` always evaluates to `AlwaysTrue` or `AlwaysFalse`, - // never `Ambiguous`. - if matches!(predicate.node, PredicateNode::ReturnsNever(_)) { - return match Self::analyze_single_cached(db, predicate, truthiness_memo) { - Truthiness::AlwaysTrue => narrow!(node.if_true, accumulated), - Truthiness::AlwaysFalse => narrow!(node.if_false, accumulated), - Truthiness::Ambiguous => { - unreachable!("ReturnsNever predicates should never be Ambiguous") - } - }; - } - // Check if this predicate narrows the variable we're interested in. let neg_predicate = Predicate { node: predicate.node, From 8468a9e271155bd2163ca2470a8da17c3461676e Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sun, 15 Feb 2026 15:36:59 +0900 Subject: [PATCH 21/69] remove `all_negative_narrowing_constraints_for_{expression, pattern}` --- crates/ty_python_semantic/src/types/narrow.rs | 520 +++++++++++------- 1 file changed, 326 insertions(+), 194 deletions(-) diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 6315c8bfd4022f..b4bdf3937bcf32 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -62,70 +62,133 @@ pub(crate) fn infer_narrowing_constraint<'db>( ) -> Option> { let constraints = match predicate.node { PredicateNode::Expression(expression) => { - if predicate.is_positive { - all_narrowing_constraints_for_expression(db, expression) - } else { - all_negative_narrowing_constraints_for_expression(db, expression) - } - } - PredicateNode::Pattern(pattern) => { - if predicate.is_positive { - all_narrowing_constraints_for_pattern(db, pattern) - } else { - all_negative_narrowing_constraints_for_pattern(db, pattern) - } + all_narrowing_constraints_for_expression(db, expression) } + PredicateNode::Pattern(pattern) => all_narrowing_constraints_for_pattern(db, pattern), PredicateNode::ReturnsNever(_) => return None, PredicateNode::StarImportPlaceholder(_) => return None, }; - constraints.and_then(|constraints| constraints.get(&place).cloned()) + constraints.and_then(|constraints| constraints.get(place, predicate.is_positive).cloned()) } -#[salsa::tracked(returns(as_ref), heap_size=ruff_memory_usage::heap_size)] -fn all_narrowing_constraints_for_pattern<'db>( - db: &'db dyn Db, - pattern: PatternPredicate<'db>, -) -> Option> { - let module = parsed_module(db, pattern.file(db)).load(db); - NarrowingConstraintsBuilder::new(db, &module, PredicateNode::Pattern(pattern), true).finish() +#[derive(Default, PartialEq, Debug, Eq, Clone, salsa::Update, get_size2::GetSize)] +struct PerPlaceDualNarrowingConstraint<'db> { + positive: Option>, + negative: Option>, } -#[salsa::tracked( - returns(as_ref), - cycle_initial=|_, _, _| None, - heap_size=ruff_memory_usage::heap_size, -)] -fn all_narrowing_constraints_for_expression<'db>( - db: &'db dyn Db, - expression: Expression<'db>, -) -> Option> { - let module = parsed_module(db, expression.file(db)).load(db); - NarrowingConstraintsBuilder::new(db, &module, PredicateNode::Expression(expression), true) - .finish() +type DualNarrowingConstraintsMap<'db> = + FxHashMap>; + +#[derive(Default, PartialEq, Debug, Eq, Clone, salsa::Update, get_size2::GetSize)] +struct DualNarrowingConstraints<'db> { + by_place: DualNarrowingConstraintsMap<'db>, + has_positive: bool, + has_negative: bool, } +impl<'db> DualNarrowingConstraints<'db> { + fn from_sides( + positive: Option>, + negative: Option>, + ) -> Self { + let mut by_place = DualNarrowingConstraintsMap::default(); + let has_positive = positive.is_some(); + let has_negative = negative.is_some(); + + if let Some(positive) = positive { + for (place, constraint) in positive { + by_place.entry(place).or_default().positive = Some(constraint); + } + } + + if let Some(negative) = negative { + for (place, constraint) in negative { + by_place.entry(place).or_default().negative = Some(constraint); + } + } + + Self { + by_place, + has_positive, + has_negative, + } + } + + fn into_sides( + self, + ) -> ( + Option>, + Option>, + ) { + let mut positive = self.has_positive.then(FxHashMap::default); + let mut negative = self.has_negative.then(FxHashMap::default); + + for (place, constraints) in self.by_place { + if let (Some(positive), Some(constraint)) = (&mut positive, constraints.positive) { + positive.insert(place, constraint); + } + if let (Some(negative), Some(constraint)) = (&mut negative, constraints.negative) { + negative.insert(place, constraint); + } + } + + (positive, negative) + } + + fn get(&self, place: ScopedPlaceId, is_positive: bool) -> Option<&NarrowingConstraint<'db>> { + if is_positive && !self.has_positive || !is_positive && !self.has_negative { + return None; + } + + self.by_place.get(&place).and_then(|constraints| { + if is_positive { + constraints.positive.as_ref() + } else { + constraints.negative.as_ref() + } + }) + } + + fn shrink_to_fit(&mut self) { + self.by_place.shrink_to_fit(); + } + + fn swap_polarity(mut self) -> Self { + std::mem::swap(&mut self.has_positive, &mut self.has_negative); + for constraints in self.by_place.values_mut() { + std::mem::swap(&mut constraints.positive, &mut constraints.negative); + } + self + } +} + +#[allow(clippy::unnecessary_wraps)] #[salsa::tracked( returns(as_ref), cycle_initial=|_, _, _| None, heap_size=ruff_memory_usage::heap_size, )] -fn all_negative_narrowing_constraints_for_expression<'db>( +fn all_narrowing_constraints_for_expression<'db>( db: &'db dyn Db, expression: Expression<'db>, -) -> Option> { +) -> Option> { let module = parsed_module(db, expression.file(db)).load(db); - NarrowingConstraintsBuilder::new(db, &module, PredicateNode::Expression(expression), false) - .finish() + Some( + NarrowingConstraintsBuilder::new(db, &module, PredicateNode::Expression(expression)) + .finish(), + ) } +#[allow(clippy::unnecessary_wraps)] #[salsa::tracked(returns(as_ref), heap_size=ruff_memory_usage::heap_size)] -fn all_negative_narrowing_constraints_for_pattern<'db>( +fn all_narrowing_constraints_for_pattern<'db>( db: &'db dyn Db, pattern: PatternPredicate<'db>, -) -> Option> { +) -> Option> { let module = parsed_module(db, pattern.file(db)).load(db); - NarrowingConstraintsBuilder::new(db, &module, PredicateNode::Pattern(pattern), false).finish() + Some(NarrowingConstraintsBuilder::new(db, &module, PredicateNode::Pattern(pattern)).finish()) } /// Functions that can be used to narrow the type of a first argument using a "classinfo" second argument. @@ -495,74 +558,94 @@ struct NarrowingConstraintsBuilder<'db, 'ast> { db: &'db dyn Db, module: &'ast ParsedModuleRef, predicate: PredicateNode<'db>, - is_positive: bool, } impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { - fn new( - db: &'db dyn Db, - module: &'ast ParsedModuleRef, - predicate: PredicateNode<'db>, - is_positive: bool, - ) -> Self { + fn new(db: &'db dyn Db, module: &'ast ParsedModuleRef, predicate: PredicateNode<'db>) -> Self { Self { db, module, predicate, - is_positive, } } - fn finish(mut self) -> Option> { - let mut constraints: Option> = match self.predicate { - PredicateNode::Expression(expression) => { - self.evaluate_expression_predicate(expression, self.is_positive) - } - PredicateNode::Pattern(pattern) => { - self.evaluate_pattern_predicate(pattern, self.is_positive) + fn finish(mut self) -> DualNarrowingConstraints<'db> { + let mut constraints = match self.predicate { + PredicateNode::Expression(expression) => self.evaluate_expression_predicate(expression), + PredicateNode::Pattern(pattern) => self.evaluate_pattern_predicate(pattern), + PredicateNode::ReturnsNever(_) | PredicateNode::StarImportPlaceholder(_) => { + return DualNarrowingConstraints::default(); } - PredicateNode::ReturnsNever(_) => return None, - PredicateNode::StarImportPlaceholder(_) => return None, }; - if let Some(ref mut constraints) = constraints { - constraints.shrink_to_fit(); - } + constraints.shrink_to_fit(); constraints } + fn merge_constraints_and_sequence( + &self, + sub_constraints: Vec>>, + ) -> Option> { + let mut aggregation: Option> = None; + for sub_constraint in sub_constraints.into_iter().flatten() { + if let Some(ref mut some_aggregation) = aggregation { + merge_constraints_and(some_aggregation, sub_constraint, self.db); + } else { + aggregation = Some(sub_constraint); + } + } + aggregation + } + + fn merge_constraints_or_sequence( + &self, + sub_constraints: Vec>>, + ) -> Option> { + let (mut first, rest) = { + let mut it = sub_constraints.into_iter(); + (it.next()?, it) + }; + + if let Some(ref mut first) = first { + for rest_constraint in rest { + if let Some(rest_constraint) = rest_constraint { + merge_constraints_or(first, rest_constraint, self.db); + } else { + return None; + } + } + } + first + } + fn evaluate_expression_predicate( &mut self, expression: Expression<'db>, - is_positive: bool, - ) -> Option> { + ) -> DualNarrowingConstraints<'db> { let expression_node = expression.node_ref(self.db, self.module); - self.evaluate_expression_node_predicate(expression_node, expression, is_positive) + self.evaluate_expression_node_predicate(expression_node, expression) } fn evaluate_expression_node_predicate( &mut self, expression_node: &ruff_python_ast::Expr, expression: Expression<'db>, - is_positive: bool, - ) -> Option> { + ) -> DualNarrowingConstraints<'db> { match expression_node { ast::Expr::Name(_) | ast::Expr::Attribute(_) | ast::Expr::Subscript(_) => { - self.evaluate_simple_expr(expression_node, is_positive) + self.evaluate_simple_expr(expression_node) } ast::Expr::Compare(expr_compare) => { - self.evaluate_expr_compare(expr_compare, expression, is_positive) - } - ast::Expr::Call(expr_call) => { - self.evaluate_expr_call(expr_call, expression, is_positive) - } - ast::Expr::UnaryOp(unary_op) if unary_op.op == ast::UnaryOp::Not => { - self.evaluate_expression_node_predicate(&unary_op.operand, expression, !is_positive) - } - ast::Expr::BoolOp(bool_op) => self.evaluate_bool_op(bool_op, expression, is_positive), - ast::Expr::Named(expr_named) => self.evaluate_expr_named(expr_named, is_positive), - _ => None, + self.evaluate_expr_compare(expr_compare, expression) + } + ast::Expr::Call(expr_call) => self.evaluate_expr_call(expr_call, expression), + ast::Expr::UnaryOp(unary_op) if unary_op.op == ast::UnaryOp::Not => self + .evaluate_expression_node_predicate(&unary_op.operand, expression) + .swap_polarity(), + ast::Expr::BoolOp(bool_op) => self.evaluate_bool_op(bool_op, expression), + ast::Expr::Named(expr_named) => self.evaluate_expr_named(expr_named), + _ => DualNarrowingConstraints::default(), } } @@ -570,38 +653,32 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, pattern_predicate_kind: &PatternPredicateKind<'db>, subject: Expression<'db>, - is_positive: bool, - ) -> Option> { + ) -> DualNarrowingConstraints<'db> { match pattern_predicate_kind { PatternPredicateKind::Singleton(singleton) => { - self.evaluate_match_pattern_singleton(subject, *singleton, is_positive) + self.evaluate_match_pattern_singleton(subject, *singleton) } PatternPredicateKind::Class(cls, kind) => { - self.evaluate_match_pattern_class(subject, *cls, *kind, is_positive) - } - PatternPredicateKind::Value(expr) => { - self.evaluate_match_pattern_value(subject, *expr, is_positive) + self.evaluate_match_pattern_class(subject, *cls, *kind) } + PatternPredicateKind::Value(expr) => self.evaluate_match_pattern_value(subject, *expr), PatternPredicateKind::Or(predicates) => { - self.evaluate_match_pattern_or(subject, predicates, is_positive) + self.evaluate_match_pattern_or(subject, predicates) } PatternPredicateKind::As(pattern, _) => pattern .as_deref() - .and_then(|p| self.evaluate_pattern_predicate_kind(p, subject, is_positive)), - PatternPredicateKind::Unsupported => None, + .map_or_else(DualNarrowingConstraints::default, |p| { + self.evaluate_pattern_predicate_kind(p, subject) + }), + PatternPredicateKind::Unsupported => DualNarrowingConstraints::default(), } } fn evaluate_pattern_predicate( &mut self, pattern: PatternPredicate<'db>, - is_positive: bool, - ) -> Option> { - self.evaluate_pattern_predicate_kind( - pattern.kind(self.db), - pattern.subject(self.db), - is_positive, - ) + ) -> DualNarrowingConstraints<'db> { + self.evaluate_pattern_predicate_kind(pattern.kind(self.db), pattern.subject(self.db)) } fn places(&self) -> &'db PlaceTable { @@ -713,32 +790,29 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { } } - fn evaluate_simple_expr( - &mut self, - expr: &ast::Expr, - is_positive: bool, - ) -> Option> { - let target = PlaceExpr::try_from_expr(expr)?; - let place = self.expect_place(&target); - - let ty = if is_positive { - Type::AlwaysFalsy.negate(self.db) - } else { - Type::AlwaysTruthy.negate(self.db) + fn evaluate_simple_expr(&mut self, expr: &ast::Expr) -> DualNarrowingConstraints<'db> { + let Some(target) = PlaceExpr::try_from_expr(expr) else { + return DualNarrowingConstraints::default(); }; + let place = self.expect_place(&target); - Some(NarrowingConstraints::from_iter([( + let positive = NarrowingConstraints::from_iter([( place, - NarrowingConstraint::intersection(ty), - )])) + NarrowingConstraint::intersection(Type::AlwaysFalsy.negate(self.db)), + )]); + let negative = NarrowingConstraints::from_iter([( + place, + NarrowingConstraint::intersection(Type::AlwaysTruthy.negate(self.db)), + )]); + + DualNarrowingConstraints::from_sides(Some(positive), Some(negative)) } fn evaluate_expr_named( &mut self, expr_named: &ast::ExprNamed, - is_positive: bool, - ) -> Option> { - self.evaluate_simple_expr(&expr_named.target, is_positive) + ) -> DualNarrowingConstraints<'db> { + self.evaluate_simple_expr(&expr_named.target) } fn evaluate_expr_eq(&mut self, lhs_ty: Type<'db>, rhs_ty: Type<'db>) -> Option> { @@ -948,10 +1022,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { lhs_ty: Type<'db>, rhs_ty: Type<'db>, op: ast::CmpOp, - is_positive: bool, ) -> Option> { - let op = if is_positive { op } else { op.negate() }; - // `Divergent` shows up as an initial value in cycle recovery. If it appears on either side // of a potentially narrowing comparison, we don't want it to turn that comparison into a // no-op (e.g. because `Divergent` is not a singleton in the `IsNot` branch below), because @@ -996,6 +1067,18 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, expr_compare: &ast::ExprCompare, expression: Expression<'db>, + ) -> DualNarrowingConstraints<'db> { + let inference = infer_expression_types(self.db, expression, TypeContext::default()); + DualNarrowingConstraints::from_sides( + self.evaluate_expr_compare_for_polarity(expr_compare, inference, true), + self.evaluate_expr_compare_for_polarity(expr_compare, inference, false), + ) + } + + fn evaluate_expr_compare_for_polarity( + &mut self, + expr_compare: &ast::ExprCompare, + inference: &ExpressionInference<'db>, is_positive: bool, ) -> Option> { fn is_narrowing_target_candidate(expr: &ast::Expr) -> bool { @@ -1070,8 +1153,6 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { return None; } - let inference = infer_expression_types(self.db, expression, TypeContext::default()); - let comparator_tuples = std::iter::once(&**left) .chain(comparators) .tuple_windows::<(&ruff_python_ast::Expr, &ruff_python_ast::Expr)>(); @@ -1274,13 +1355,13 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { // If this is `None`, it indicates that we cannot do `if type(x) is Y` // narrowing: we can only do narrowing for `if type(x) is Y` and // `if type(x) is not Y`, not for `if type(x) == Y` or `if type(x) != Y`. - let is_positive = match op { + let type_narrowing_is_positive = match op { ast::CmpOp::Is => Some(is_positive), ast::CmpOp::IsNot => Some(!is_positive), _ => None, }; - if let Some(is_positive) = is_positive + if let Some(type_narrowing_is_positive) = type_narrowing_is_positive && keywords.is_empty() && let [single_argument] = &**args && let Some(target) = PlaceExpr::try_from_expr(single_argument) @@ -1289,14 +1370,14 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { && let Some(other_class) = find_underlying_class(self.db, other) // `else`-branch narrowing for `if type(x) is Y` can only be done // if `Y` is a final class - && (is_positive || other_class.is_final(self.db)) + && (type_narrowing_is_positive || other_class.is_final(self.db)) { let place = self.expect_place(&target); constraints.insert( place, NarrowingConstraint::intersection( Type::instance(self.db, other_class.top_materialization(self.db)) - .negate_if(self.db, !is_positive), + .negate_if(self.db, !type_narrowing_is_positive), ), ); last_rhs_ty = Some(rhs_ty); @@ -1313,7 +1394,11 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { // - `if x not in y` if narrowable_ast(left) && let Some(narrowable) = PlaceExpr::try_from_expr(left) - && let Some(ty) = self.evaluate_expr_compare_op(lhs_ty, rhs_ty, *op, is_positive) + && let Some(ty) = self.evaluate_expr_compare_op( + lhs_ty, + rhs_ty, + if is_positive { *op } else { op.negate() }, + ) { let place = self.expect_place(&narrowable); let constraint = NarrowingConstraint::intersection(ty); @@ -1335,7 +1420,11 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { if !matches!(op, ast::CmpOp::In | ast::CmpOp::NotIn) && narrowable_ast(right) && let Some(narrowable) = PlaceExpr::try_from_expr(right) - && let Some(ty) = self.evaluate_expr_compare_op(rhs_ty, lhs_ty, *op, is_positive) + && let Some(ty) = self.evaluate_expr_compare_op( + rhs_ty, + lhs_ty, + if is_positive { *op } else { op.negate() }, + ) { let place = self.expect_place(&narrowable); let constraint = NarrowingConstraint::intersection(ty); @@ -1359,12 +1448,32 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, expr_call: &ast::ExprCall, expression: Expression<'db>, - is_positive: bool, - ) -> Option> { + ) -> DualNarrowingConstraints<'db> { let inference = infer_expression_types(self.db, expression, TypeContext::default()); - let callable_ty = inference.expression_type(&*expr_call.func); + if let Type::ClassLiteral(class_type) = callable_ty + && expr_call.arguments.args.len() == 1 + && expr_call.arguments.keywords.is_empty() + && class_type.is_known(self.db, KnownClass::Bool) + { + return self + .evaluate_expression_node_predicate(&expr_call.arguments.args[0], expression); + } + + DualNarrowingConstraints::from_sides( + self.evaluate_expr_call_for_polarity(expr_call, inference, callable_ty, true), + self.evaluate_expr_call_for_polarity(expr_call, inference, callable_ty, false), + ) + } + + fn evaluate_expr_call_for_polarity( + &mut self, + expr_call: &ast::ExprCall, + inference: &ExpressionInference<'db>, + callable_ty: Type<'db>, + is_positive: bool, + ) -> Option> { match callable_ty { Type::FunctionLiteral(function_type) if matches!( @@ -1372,10 +1481,10 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { None | Some(KnownFunction::RevealType) ) => { - self.evaluate_type_guard_call(inference, expr_call, is_positive) + self.evaluate_type_guard_call_for_polarity(inference, expr_call, is_positive) } Type::BoundMethod(_) => { - self.evaluate_type_guard_call(inference, expr_call, is_positive) + self.evaluate_type_guard_call_for_polarity(inference, expr_call, is_positive) } // For the expression `len(E)`, we narrow the type based on whether len(E) is truthy // (i.e., whether E is non-empty). We only narrow the parts of the type where we know @@ -1447,25 +1556,13 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { )]) }) } - // for the expression `bool(E)`, we further narrow the type based on `E` - Type::ClassLiteral(class_type) - if expr_call.arguments.args.len() == 1 - && expr_call.arguments.keywords.is_empty() - && class_type.is_known(self.db, KnownClass::Bool) => - { - self.evaluate_expression_node_predicate( - &expr_call.arguments.args[0], - expression, - is_positive, - ) - } _ => None, } } // Helper to evaluate TypeGuard/TypeIs narrowing for a call expression. // Used for both direct function calls and bound method calls. - fn evaluate_type_guard_call( + fn evaluate_type_guard_call_for_polarity( &mut self, inference: &ExpressionInference<'db>, expr_call: &ast::ExprCall, @@ -1503,6 +1600,17 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, subject: Expression<'db>, singleton: ast::Singleton, + ) -> DualNarrowingConstraints<'db> { + DualNarrowingConstraints::from_sides( + self.evaluate_match_pattern_singleton_for_polarity(subject, singleton, true), + self.evaluate_match_pattern_singleton_for_polarity(subject, singleton, false), + ) + } + + fn evaluate_match_pattern_singleton_for_polarity( + &mut self, + subject: Expression<'db>, + singleton: ast::Singleton, is_positive: bool, ) -> Option> { let subject = PlaceExpr::try_from_expr(subject.node_ref(self.db, self.module))?; @@ -1525,6 +1633,18 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { subject: Expression<'db>, cls: Expression<'db>, kind: ClassPatternKind, + ) -> DualNarrowingConstraints<'db> { + DualNarrowingConstraints::from_sides( + self.evaluate_match_pattern_class_for_polarity(subject, cls, kind, true), + self.evaluate_match_pattern_class_for_polarity(subject, cls, kind, false), + ) + } + + fn evaluate_match_pattern_class_for_polarity( + &mut self, + subject: Expression<'db>, + cls: Expression<'db>, + kind: ClassPatternKind, is_positive: bool, ) -> Option> { if !kind.is_irrefutable() && !is_positive { @@ -1559,6 +1679,17 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, subject: Expression<'db>, value: Expression<'db>, + ) -> DualNarrowingConstraints<'db> { + DualNarrowingConstraints::from_sides( + self.evaluate_match_pattern_value_for_polarity(subject, value, true), + self.evaluate_match_pattern_value_for_polarity(subject, value, false), + ) + } + + fn evaluate_match_pattern_value_for_polarity( + &mut self, + subject: Expression<'db>, + value: Expression<'db>, is_positive: bool, ) -> Option> { let subject_node = subject.node_ref(self.db, self.module); @@ -1573,7 +1704,15 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { infer_same_file_expression_type(self.db, value, TypeContext::default(), self.module); let mut constraints = self - .evaluate_expr_compare_op(subject_ty, value_ty, ast::CmpOp::Eq, is_positive) + .evaluate_expr_compare_op( + subject_ty, + value_ty, + if is_positive { + ast::CmpOp::Eq + } else { + ast::CmpOp::NotEq + }, + ) .map(|ty| { NarrowingConstraints::from_iter([(place, NarrowingConstraint::intersection(ty))]) }) @@ -1621,39 +1760,45 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, subject: Expression<'db>, predicates: &Vec>, - is_positive: bool, - ) -> Option> { - let db = self.db; + ) -> DualNarrowingConstraints<'db> { + let mut positive: Option> = None; + let mut negative: Option> = None; + + for predicate in predicates { + let (sub_positive, sub_negative) = self + .evaluate_pattern_predicate_kind(predicate, subject) + .into_sides(); + + if let Some(sub_positive) = sub_positive { + if let Some(ref mut aggregated) = positive { + merge_constraints_or(aggregated, sub_positive, self.db); + } else { + positive = Some(sub_positive); + } + } - // DeMorgan's law---if the overall `or` is negated, we need to `and` the negated sub-constraints. - let merge_constraints = if is_positive { - merge_constraints_or - } else { - merge_constraints_and - }; + if let Some(sub_negative) = sub_negative { + if let Some(ref mut aggregated) = negative { + merge_constraints_and(aggregated, sub_negative, self.db); + } else { + negative = Some(sub_negative); + } + } + } - predicates - .iter() - .filter_map(|predicate| { - self.evaluate_pattern_predicate_kind(predicate, subject, is_positive) - }) - .reduce(|mut constraints, constraints_| { - merge_constraints(&mut constraints, constraints_, db); - constraints - }) + DualNarrowingConstraints::from_sides(positive, negative) } fn evaluate_bool_op( &mut self, expr_bool_op: &ExprBoolOp, expression: Expression<'db>, - is_positive: bool, - ) -> Option> { + ) -> DualNarrowingConstraints<'db> { let inference = infer_expression_types(self.db, expression, TypeContext::default()); let sub_constraints = expr_bool_op .values .iter() - // filter our arms with statically known truthiness + // Filter out arms with statically known truthiness. .filter(|expr| { inference.expression_type(*expr).bool(self.db) != match expr_bool_op.op { @@ -1661,40 +1806,27 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { BoolOp::Or => Truthiness::AlwaysFalse, } }) - .map(|sub_expr| { - self.evaluate_expression_node_predicate(sub_expr, expression, is_positive) - }) + .map(|sub_expr| self.evaluate_expression_node_predicate(sub_expr, expression)) .collect::>(); - match (expr_bool_op.op, is_positive) { - (BoolOp::And, true) | (BoolOp::Or, false) => { - let mut aggregation: Option = None; - for sub_constraint in sub_constraints.into_iter().flatten() { - if let Some(ref mut some_aggregation) = aggregation { - merge_constraints_and(some_aggregation, sub_constraint, self.db); - } else { - aggregation = Some(sub_constraint); - } - } - aggregation - } - (BoolOp::Or, true) | (BoolOp::And, false) => { - let (mut first, rest) = { - let mut it = sub_constraints.into_iter(); - (it.next()?, it) - }; - if let Some(ref mut first) = first { - for rest_constraint in rest { - if let Some(rest_constraint) = rest_constraint { - merge_constraints_or(first, rest_constraint, self.db); - } else { - return None; - } - } - } - first - } - } + let (positive_sub_constraints, negative_sub_constraints): (Vec<_>, Vec<_>) = + sub_constraints + .into_iter() + .map(DualNarrowingConstraints::into_sides) + .unzip(); + + let (positive, negative) = match expr_bool_op.op { + BoolOp::And => ( + self.merge_constraints_and_sequence(positive_sub_constraints), + self.merge_constraints_or_sequence(negative_sub_constraints), + ), + BoolOp::Or => ( + self.merge_constraints_or_sequence(positive_sub_constraints), + self.merge_constraints_and_sequence(negative_sub_constraints), + ), + }; + + DualNarrowingConstraints::from_sides(positive, negative) } /// Narrow tagged unions of `TypedDict`s with `Literal` keys. From c2fe06e6a100792da39317ed38ab009189a30a06 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sun, 15 Feb 2026 16:23:52 +0900 Subject: [PATCH 22/69] compact `PredicatePlaceVersions` --- .../reachability_constraints.rs | 26 +++-- .../src/semantic_index/use_def.rs | 107 +++++++++++++++--- 2 files changed, 105 insertions(+), 28 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 1a62fe428dd190..05a7ac47834eab 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -875,17 +875,19 @@ impl ReachabilityConstraints { node: predicate.node, is_positive: !predicate.is_positive, }; - let place_version_info = predicate_place_versions.get(&(node.atom, place)); - let (pos_constraint, neg_constraint) = if place_version_info.is_some() { - ( - infer_narrowing_constraint(db, predicate, place), - infer_narrowing_constraint(db, neg_predicate, place), - ) - } else { - // No recorded place-version metadata means this predicate cannot narrow - // this place, so skip the expensive narrowing-inference queries. - (None, None) - }; + let predicate_place_key = (node.atom, place); + let place_version_info = predicate_place_versions.get(predicate_place_key); + let (pos_constraint, neg_constraint) = + if predicate_place_versions.contains(predicate_place_key) { + ( + infer_narrowing_constraint(db, predicate, place), + infer_narrowing_constraint(db, neg_predicate, place), + ) + } else { + // No recorded place-version metadata means this predicate cannot narrow + // this place, so skip the expensive narrowing-inference queries. + (None, None) + }; // Only gate by place version if this predicate can narrow the current place. // Predicates unrelated to `place` are still useful for reachability pruning. @@ -952,7 +954,7 @@ impl ReachabilityConstraints { binding_place_version: Option, ) -> bool { binding_place_version.is_none_or(|binding_place_version| { - place_version_info.is_some_and(|info| { + place_version_info.map_or(binding_place_version == PlaceVersion::default(), |info| { info.versions.contains(&binding_place_version) || info.allow_future_versions && info diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index 34ef58cc9d9855..b4e9d954fe3d9e 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -279,8 +279,92 @@ pub(crate) struct PredicatePlaceVersionInfo { pub(crate) allow_future_versions: bool, } -pub(crate) type PredicatePlaceVersions = - FxHashMap<(ScopedPredicateId, ScopedPlaceId), PredicatePlaceVersionInfo>; +#[derive(Clone, Debug, Default, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +pub(crate) struct PredicatePlaceVersions { + /// All `(predicate, place)` pairs for which narrowing may apply. + /// + /// Most pairs only ever need the default semantics (`version == 0`, no future versions). + /// Those are stored here only, without an auxiliary payload. + applicable_pairs: FxHashSet<(ScopedPredicateId, ScopedPlaceId)>, + /// Non-default version information for applicable pairs. + /// + /// If a key is absent here but present in `applicable_pairs`, the pair uses the implicit + /// default version info (`versions = [0]`, `allow_future_versions = false`). + non_default_info: FxHashMap<(ScopedPredicateId, ScopedPlaceId), PredicatePlaceVersionInfo>, +} + +impl PredicatePlaceVersions { + pub(crate) fn contains(&self, key: (ScopedPredicateId, ScopedPlaceId)) -> bool { + self.applicable_pairs.contains(&key) + } + + pub(crate) fn get( + &self, + key: (ScopedPredicateId, ScopedPlaceId), + ) -> Option<&PredicatePlaceVersionInfo> { + self.non_default_info.get(&key) + } + + pub(crate) fn shrink_to_fit(&mut self) { + self.applicable_pairs.shrink_to_fit(); + self.non_default_info.shrink_to_fit(); + } + + pub(crate) fn record( + &mut self, + key: (ScopedPredicateId, ScopedPlaceId), + versions: impl Iterator, + allow_future_versions: bool, + ) { + let default_version = PlaceVersion::default(); + let had_implicit_default = + self.applicable_pairs.contains(&key) && !self.non_default_info.contains_key(&key); + self.applicable_pairs.insert(key); + + let mut incoming_versions: SmallVec<[PlaceVersion; 2]> = SmallVec::new(); + for version in versions { + if !incoming_versions.contains(&version) { + incoming_versions.push(version); + } + } + + if !allow_future_versions + && !had_implicit_default + && !self.non_default_info.contains_key(&key) + && matches!(&*incoming_versions, [v] if *v == default_version) + { + // Keep the key only in `applicable_pairs`; no payload needed. + return; + } + + let entry = self.non_default_info.entry(key).or_default(); + if had_implicit_default && !entry.versions.contains(&default_version) { + entry.versions.push(default_version); + entry.max_version = Some( + entry + .max_version + .map_or(default_version, |m| m.max(default_version)), + ); + } + + if allow_future_versions { + entry.allow_future_versions = true; + } + + for version in incoming_versions { + if !entry.versions.contains(&version) { + entry.versions.push(version); + entry.max_version = Some(entry.max_version.map_or(version, |max| max.max(version))); + } + } + + let is_default = !entry.allow_future_versions + && matches!(&*entry.versions, [v] if *v == default_version); + if is_default { + self.non_default_info.remove(&key); + } + } +} /// Applicable definitions and constraints for every use of a name. #[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] @@ -1092,20 +1176,11 @@ impl<'db> UseDefMapBuilder<'db> { continue; }; - let entry = self - .predicate_place_versions - .entry((predicate, *place)) - .or_default(); - if allow_future_versions_for.contains(place) { - entry.allow_future_versions = true; - } - for version in bindings.place_versions() { - if !entry.versions.contains(&version) { - entry.versions.push(version); - entry.max_version = - Some(entry.max_version.map_or(version, |max| max.max(version))); - } - } + self.predicate_place_versions.record( + (predicate, *place), + bindings.place_versions(), + allow_future_versions_for.contains(place), + ); } } From 6da35464bfdba29c7b9f0b9b8f7a767ea644e96a Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sun, 15 Feb 2026 16:54:01 +0900 Subject: [PATCH 23/69] Revert "compact `PredicatePlaceVersions`" This reverts commit c2fe06e6a100792da39317ed38ab009189a30a06. --- .../reachability_constraints.rs | 26 ++--- .../src/semantic_index/use_def.rs | 107 +++--------------- 2 files changed, 28 insertions(+), 105 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 05a7ac47834eab..1a62fe428dd190 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -875,19 +875,17 @@ impl ReachabilityConstraints { node: predicate.node, is_positive: !predicate.is_positive, }; - let predicate_place_key = (node.atom, place); - let place_version_info = predicate_place_versions.get(predicate_place_key); - let (pos_constraint, neg_constraint) = - if predicate_place_versions.contains(predicate_place_key) { - ( - infer_narrowing_constraint(db, predicate, place), - infer_narrowing_constraint(db, neg_predicate, place), - ) - } else { - // No recorded place-version metadata means this predicate cannot narrow - // this place, so skip the expensive narrowing-inference queries. - (None, None) - }; + let place_version_info = predicate_place_versions.get(&(node.atom, place)); + let (pos_constraint, neg_constraint) = if place_version_info.is_some() { + ( + infer_narrowing_constraint(db, predicate, place), + infer_narrowing_constraint(db, neg_predicate, place), + ) + } else { + // No recorded place-version metadata means this predicate cannot narrow + // this place, so skip the expensive narrowing-inference queries. + (None, None) + }; // Only gate by place version if this predicate can narrow the current place. // Predicates unrelated to `place` are still useful for reachability pruning. @@ -954,7 +952,7 @@ impl ReachabilityConstraints { binding_place_version: Option, ) -> bool { binding_place_version.is_none_or(|binding_place_version| { - place_version_info.map_or(binding_place_version == PlaceVersion::default(), |info| { + place_version_info.is_some_and(|info| { info.versions.contains(&binding_place_version) || info.allow_future_versions && info diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index b4e9d954fe3d9e..34ef58cc9d9855 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -279,92 +279,8 @@ pub(crate) struct PredicatePlaceVersionInfo { pub(crate) allow_future_versions: bool, } -#[derive(Clone, Debug, Default, PartialEq, Eq, salsa::Update, get_size2::GetSize)] -pub(crate) struct PredicatePlaceVersions { - /// All `(predicate, place)` pairs for which narrowing may apply. - /// - /// Most pairs only ever need the default semantics (`version == 0`, no future versions). - /// Those are stored here only, without an auxiliary payload. - applicable_pairs: FxHashSet<(ScopedPredicateId, ScopedPlaceId)>, - /// Non-default version information for applicable pairs. - /// - /// If a key is absent here but present in `applicable_pairs`, the pair uses the implicit - /// default version info (`versions = [0]`, `allow_future_versions = false`). - non_default_info: FxHashMap<(ScopedPredicateId, ScopedPlaceId), PredicatePlaceVersionInfo>, -} - -impl PredicatePlaceVersions { - pub(crate) fn contains(&self, key: (ScopedPredicateId, ScopedPlaceId)) -> bool { - self.applicable_pairs.contains(&key) - } - - pub(crate) fn get( - &self, - key: (ScopedPredicateId, ScopedPlaceId), - ) -> Option<&PredicatePlaceVersionInfo> { - self.non_default_info.get(&key) - } - - pub(crate) fn shrink_to_fit(&mut self) { - self.applicable_pairs.shrink_to_fit(); - self.non_default_info.shrink_to_fit(); - } - - pub(crate) fn record( - &mut self, - key: (ScopedPredicateId, ScopedPlaceId), - versions: impl Iterator, - allow_future_versions: bool, - ) { - let default_version = PlaceVersion::default(); - let had_implicit_default = - self.applicable_pairs.contains(&key) && !self.non_default_info.contains_key(&key); - self.applicable_pairs.insert(key); - - let mut incoming_versions: SmallVec<[PlaceVersion; 2]> = SmallVec::new(); - for version in versions { - if !incoming_versions.contains(&version) { - incoming_versions.push(version); - } - } - - if !allow_future_versions - && !had_implicit_default - && !self.non_default_info.contains_key(&key) - && matches!(&*incoming_versions, [v] if *v == default_version) - { - // Keep the key only in `applicable_pairs`; no payload needed. - return; - } - - let entry = self.non_default_info.entry(key).or_default(); - if had_implicit_default && !entry.versions.contains(&default_version) { - entry.versions.push(default_version); - entry.max_version = Some( - entry - .max_version - .map_or(default_version, |m| m.max(default_version)), - ); - } - - if allow_future_versions { - entry.allow_future_versions = true; - } - - for version in incoming_versions { - if !entry.versions.contains(&version) { - entry.versions.push(version); - entry.max_version = Some(entry.max_version.map_or(version, |max| max.max(version))); - } - } - - let is_default = !entry.allow_future_versions - && matches!(&*entry.versions, [v] if *v == default_version); - if is_default { - self.non_default_info.remove(&key); - } - } -} +pub(crate) type PredicatePlaceVersions = + FxHashMap<(ScopedPredicateId, ScopedPlaceId), PredicatePlaceVersionInfo>; /// Applicable definitions and constraints for every use of a name. #[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] @@ -1176,11 +1092,20 @@ impl<'db> UseDefMapBuilder<'db> { continue; }; - self.predicate_place_versions.record( - (predicate, *place), - bindings.place_versions(), - allow_future_versions_for.contains(place), - ); + let entry = self + .predicate_place_versions + .entry((predicate, *place)) + .or_default(); + if allow_future_versions_for.contains(place) { + entry.allow_future_versions = true; + } + for version in bindings.place_versions() { + if !entry.versions.contains(&version) { + entry.versions.push(version); + entry.max_version = + Some(entry.max_version.map_or(version, |max| max.max(version))); + } + } } } From 3c0be323d2ad0b8264350eb6a6c7210c78557910 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sun, 15 Feb 2026 17:06:45 +0900 Subject: [PATCH 24/69] store place versions per definition in `UseDefMap` --- .../src/semantic_index/use_def.rs | 39 ++++++++++++++++--- .../src/semantic_index/use_def/place_state.rs | 18 +++------ 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index 34ef58cc9d9855..66d1fb3848e347 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -292,6 +292,12 @@ pub(crate) struct UseDefMap<'db> { /// Array of predicates in this scope. predicates: Predicates<'db>, + /// Place version associated with each definition ID. + /// + /// This stores the version once per definition instead of duplicating it in every `LiveBinding` + /// clone across `bindings_by_use` / snapshots. + definition_place_versions: IndexVec, + /// Place versions to which a given predicate occurrence can apply for narrowing. predicate_place_versions: PredicatePlaceVersions, @@ -673,6 +679,7 @@ impl<'db> UseDefMap<'db> { ) -> BindingWithConstraintsIterator<'map, 'db> { BindingWithConstraintsIterator { all_definitions: &self.all_definitions, + definition_place_versions: &self.definition_place_versions, predicates: &self.predicates, predicate_place_versions: &self.predicate_place_versions, reachability_constraints: &self.reachability_constraints, @@ -729,6 +736,7 @@ type EnclosingSnapshots = IndexVec #[derive(Clone, Debug)] pub(crate) struct BindingWithConstraintsIterator<'map, 'db> { pub(crate) all_definitions: &'map IndexVec>, + definition_place_versions: &'map IndexVec, pub(crate) predicates: &'map Predicates<'db>, pub(crate) predicate_place_versions: &'map PredicatePlaceVersions, pub(crate) reachability_constraints: &'map ReachabilityConstraints, @@ -753,7 +761,9 @@ impl<'map, 'db> Iterator for BindingWithConstraintsIterator<'map, 'db> { predicates, predicate_place_versions, reachability_constraints, - binding_place_version: Some(live_binding.place_version), + binding_place_version: Some( + self.definition_place_versions[live_binding.binding], + ), }, reachability_constraint: live_binding.reachability_constraint, }) @@ -856,6 +866,9 @@ pub(super) struct UseDefMapBuilder<'db> { /// Append-only array of [`DefinitionState`]. all_definitions: IndexVec>, + /// Place version associated with each definition ID. + definition_place_versions: IndexVec, + /// Builder of predicates. pub(super) predicates: PredicatesBuilder<'db>, @@ -903,6 +916,7 @@ impl<'db> UseDefMapBuilder<'db> { pub(super) fn new(is_class_scope: bool) -> Self { Self { all_definitions: IndexVec::from_iter([DefinitionState::Undefined]), + definition_place_versions: IndexVec::from_iter([PlaceVersion::default()]), predicates: PredicatesBuilder::default(), predicate_place_versions: PredicatePlaceVersions::default(), reachability_constraints: ReachabilityConstraintsBuilder::default(), @@ -991,13 +1005,15 @@ impl<'db> UseDefMapBuilder<'db> { self.declarations_by_binding .insert(binding, place_state.declarations().clone()); - place_state.record_binding( + let place_version = place_state.record_binding( def_id, self.reachability, self.is_class_scope, place.is_symbol(), previous_definitions, ); + let version_id = self.definition_place_versions.push(place_version); + debug_assert_eq!(def_id, version_id); let bindings = match place { ScopedPlaceId::Symbol(symbol) => { @@ -1092,6 +1108,10 @@ impl<'db> UseDefMapBuilder<'db> { continue; }; + let versions: SmallVec<[PlaceVersion; 2]> = bindings + .iter() + .map(|binding| self.definition_place_versions[binding.binding]) + .collect(); let entry = self .predicate_place_versions .entry((predicate, *place)) @@ -1099,7 +1119,7 @@ impl<'db> UseDefMapBuilder<'db> { if allow_future_versions_for.contains(place) { entry.allow_future_versions = true; } - for version in bindings.place_versions() { + for version in versions { if !entry.versions.contains(&version) { entry.versions.push(version); entry.max_version = @@ -1276,6 +1296,8 @@ impl<'db> UseDefMapBuilder<'db> { let def_id = self .all_definitions .push(DefinitionState::Defined(declaration)); + let version_id = self.definition_place_versions.push(PlaceVersion::default()); + debug_assert_eq!(def_id, version_id); let place_state = match place { ScopedPlaceId::Symbol(symbol) => &mut self.symbol_states[symbol], @@ -1313,13 +1335,15 @@ impl<'db> UseDefMapBuilder<'db> { ScopedPlaceId::Member(member) => &mut self.member_states[member], }; place_state.record_declaration(def_id, self.reachability); - place_state.record_binding( + let place_version = place_state.record_binding( def_id, self.reachability, self.is_class_scope, place.is_symbol(), PreviousDefinitions::AreShadowed, ); + let version_id = self.definition_place_versions.push(place_version); + debug_assert_eq!(def_id, version_id); let reachable_definitions = match place { ScopedPlaceId::Symbol(symbol) => &mut self.reachable_symbol_definitions[symbol], @@ -1346,14 +1370,15 @@ impl<'db> UseDefMapBuilder<'db> { ScopedPlaceId::Symbol(symbol) => &mut self.symbol_states[symbol], ScopedPlaceId::Member(member) => &mut self.member_states[member], }; - - place_state.record_binding( + let place_version = place_state.record_binding( def_id, self.reachability, self.is_class_scope, place.is_symbol(), PreviousDefinitions::AreShadowed, ); + let version_id = self.definition_place_versions.push(place_version); + debug_assert_eq!(def_id, version_id); } pub(super) fn record_use( @@ -1578,6 +1603,7 @@ impl<'db> UseDefMapBuilder<'db> { self.mark_reachability_constraints(); self.all_definitions.shrink_to_fit(); + self.definition_place_versions.shrink_to_fit(); self.symbol_states.shrink_to_fit(); self.member_states.shrink_to_fit(); self.reachable_symbol_definitions.shrink_to_fit(); @@ -1592,6 +1618,7 @@ impl<'db> UseDefMapBuilder<'db> { UseDefMap { all_definitions: self.all_definitions, predicates: self.predicates.build(), + definition_place_versions: self.definition_place_versions, predicate_place_versions: self.predicate_place_versions, reachability_constraints: self.reachability_constraints.build(), bindings_by_use: self.bindings_by_use, diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index d933387266be39..51fa486c679ba8 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -263,7 +263,6 @@ pub(crate) struct LiveBinding { pub(crate) binding: ScopedDefinitionId, pub(crate) narrowing_constraint: ScopedNarrowingConstraint, pub(crate) reachability_constraint: ScopedReachabilityConstraintId, - pub(crate) place_version: PlaceVersion, } pub(super) type LiveBindingsIterator<'a> = std::slice::Iter<'a, LiveBinding>; @@ -274,7 +273,6 @@ impl Bindings { binding: ScopedDefinitionId::UNBOUND, narrowing_constraint: ScopedNarrowingConstraint::ALWAYS_TRUE, reachability_constraint, - place_version: PlaceVersion::default(), }; Self { unbound_narrowing_constraint: None, @@ -291,7 +289,7 @@ impl Bindings { is_class_scope: bool, is_place_name: bool, previous_definitions: PreviousDefinitions, - ) { + ) -> PlaceVersion { // If we are in a class scope, and the unbound name binding was previously visible, but we will // now replace it, record the narrowing constraints on it: if is_class_scope && is_place_name && self.live_bindings[0].binding.is_unbound() { @@ -303,12 +301,13 @@ impl Bindings { self.live_bindings.clear(); self.latest_place_version = self.latest_place_version.next(); } + let place_version = self.latest_place_version; self.live_bindings.push(LiveBinding { binding, narrowing_constraint: ScopedNarrowingConstraint::ALWAYS_TRUE, reachability_constraint, - place_version: self.latest_place_version, }); + place_version } /// Add given constraint to all live bindings. @@ -340,12 +339,6 @@ impl Bindings { self.live_bindings.iter() } - pub(super) fn place_versions(&self) -> impl Iterator + '_ { - self.live_bindings - .iter() - .map(|binding| binding.place_version) - } - pub(super) fn merge( &mut self, b: Self, @@ -399,7 +392,6 @@ impl Bindings { binding: a.binding, narrowing_constraint, reachability_constraint, - place_version: a.place_version.max(b.place_version), }); } @@ -434,7 +426,7 @@ impl PlaceState { is_class_scope: bool, is_place_name: bool, previous_definitions: PreviousDefinitions, - ) { + ) -> PlaceVersion { debug_assert_ne!(binding_id, ScopedDefinitionId::UNBOUND); self.bindings.record_binding( binding_id, @@ -442,7 +434,7 @@ impl PlaceState { is_class_scope, is_place_name, previous_definitions, - ); + ) } /// Add given constraint to all live bindings. From 9142acdc73255d0b71c11c74e202e765c2d3f1a7 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sun, 15 Feb 2026 17:56:33 +0900 Subject: [PATCH 25/69] remove `latest_place_version` from `Bindings` --- .../src/semantic_index/use_def.rs | 59 ++++++++----- .../src/semantic_index/use_def/place_state.rs | 88 ++++++++++++------- 2 files changed, 94 insertions(+), 53 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index 66d1fb3848e347..b33f20f2870ccb 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -261,8 +261,8 @@ use crate::semantic_index::reachability_constraints::{ use crate::semantic_index::scope::{FileScopeId, ScopeKind, ScopeLaziness}; use crate::semantic_index::symbol::ScopedSymbolId; use crate::semantic_index::use_def::place_state::{ - Bindings, Declarations, EnclosingSnapshot, LiveBindingsIterator, LiveDeclaration, - LiveDeclarationsIterator, PlaceState, + Bindings, BuilderPlaceState, Declarations, EnclosingSnapshot, LiveBindingsIterator, + LiveDeclaration, LiveDeclarationsIterator, PlaceState, }; use crate::semantic_index::{EnclosingSnapshotResult, SemanticIndex}; use crate::types::{PossiblyNarrowedPlaces, Truthiness, Type}; @@ -849,16 +849,16 @@ struct ReachableDefinitions { /// A snapshot of the definitions and constraints state at a particular point in control flow. #[derive(Clone, Debug)] pub(super) struct FlowSnapshot { - symbol_states: IndexVec, - member_states: IndexVec, + symbol_states: IndexVec, + member_states: IndexVec, reachability: ScopedReachabilityConstraintId, } /// A snapshot of the state of a single symbol (e.g. `obj`) and all of its associated members /// (e.g. `obj.attr`, `obj["key"]`). pub(super) struct SingleSymbolSnapshot { - symbol_state: PlaceState, - associated_member_states: FxHashMap, + symbol_state: BuilderPlaceState, + associated_member_states: FxHashMap, } #[derive(Debug)] @@ -895,9 +895,9 @@ pub(super) struct UseDefMapBuilder<'db> { bindings_by_definition: FxHashMap, Bindings>, /// Currently live bindings and declarations for each place. - symbol_states: IndexVec, + symbol_states: IndexVec, - member_states: IndexVec, + member_states: IndexVec, /// All potentially reachable bindings and declarations, for each place. reachable_symbol_definitions: IndexVec, @@ -957,7 +957,7 @@ impl<'db> UseDefMapBuilder<'db> { ScopedPlaceId::Symbol(symbol) => { let new_place = self .symbol_states - .push(PlaceState::undefined(self.reachability)); + .push(BuilderPlaceState::undefined(self.reachability)); debug_assert_eq!(symbol, new_place); let new_place = self .reachable_symbol_definitions @@ -970,7 +970,7 @@ impl<'db> UseDefMapBuilder<'db> { ScopedPlaceId::Member(member) => { let new_place = self .member_states - .push(PlaceState::undefined(self.reachability)); + .push(BuilderPlaceState::undefined(self.reachability)); debug_assert_eq!(member, new_place); let new_place = self .reachable_member_definitions @@ -1030,6 +1030,7 @@ impl<'db> UseDefMapBuilder<'db> { self.is_class_scope, place.is_symbol(), PreviousDefinitions::AreKept, + place_version, ); } @@ -1097,12 +1098,14 @@ impl<'db> UseDefMapBuilder<'db> { ) { for place in places { let bindings = match place { - ScopedPlaceId::Symbol(symbol_id) => { - self.symbol_states.get(*symbol_id).map(PlaceState::bindings) - } - ScopedPlaceId::Member(member_id) => { - self.member_states.get(*member_id).map(PlaceState::bindings) - } + ScopedPlaceId::Symbol(symbol_id) => self + .symbol_states + .get(*symbol_id) + .map(BuilderPlaceState::bindings), + ScopedPlaceId::Member(member_id) => self + .member_states + .get(*member_id) + .map(BuilderPlaceState::bindings), }; let Some(bindings) = bindings else { continue; @@ -1361,6 +1364,7 @@ impl<'db> UseDefMapBuilder<'db> { self.is_class_scope, place.is_symbol(), PreviousDefinitions::AreKept, + place_version, ); } @@ -1494,10 +1498,10 @@ impl<'db> UseDefMapBuilder<'db> { // to fill them in so the place IDs continue to line up. Since they don't exist in the // snapshot, the correct state to fill them in with is "undefined". self.symbol_states - .resize(num_symbols, PlaceState::undefined(self.reachability)); + .resize(num_symbols, BuilderPlaceState::undefined(self.reachability)); self.member_states - .resize(num_members, PlaceState::undefined(self.reachability)); + .resize(num_members, BuilderPlaceState::undefined(self.reachability)); } /// Merge the given snapshot into the current state, reflecting that we might have taken either @@ -1531,7 +1535,7 @@ impl<'db> UseDefMapBuilder<'db> { current.merge(snapshot, &mut self.reachability_constraints); } else { current.merge( - PlaceState::undefined(snapshot.reachability), + BuilderPlaceState::undefined(snapshot.reachability), &mut self.reachability_constraints, ); // Place not present in snapshot, so it's unbound/undeclared from that path. @@ -1544,7 +1548,7 @@ impl<'db> UseDefMapBuilder<'db> { current.merge(snapshot, &mut self.reachability_constraints); } else { current.merge( - PlaceState::undefined(snapshot.reachability), + BuilderPlaceState::undefined(snapshot.reachability), &mut self.reachability_constraints, ); // Place not present in snapshot, so it's unbound/undeclared from that path. @@ -1615,6 +1619,17 @@ impl<'db> UseDefMapBuilder<'db> { self.bindings_by_definition.shrink_to_fit(); self.enclosing_snapshots.shrink_to_fit(); + let end_of_scope_symbols: IndexVec = self + .symbol_states + .into_iter() + .map(BuilderPlaceState::into_place_state) + .collect(); + let end_of_scope_members: IndexVec = self + .member_states + .into_iter() + .map(BuilderPlaceState::into_place_state) + .collect(); + UseDefMap { all_definitions: self.all_definitions, predicates: self.predicates.build(), @@ -1623,8 +1638,8 @@ impl<'db> UseDefMapBuilder<'db> { reachability_constraints: self.reachability_constraints.build(), bindings_by_use: self.bindings_by_use, node_reachability: self.node_reachability, - end_of_scope_symbols: self.symbol_states, - end_of_scope_members: self.member_states, + end_of_scope_symbols, + end_of_scope_members, reachable_definitions_by_symbol: self.reachable_symbol_definitions, reachable_definitions_by_member: self.reachable_member_definitions, declarations_by_binding: self.declarations_by_binding, diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index 51fa486c679ba8..80d771caa3c444 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -238,8 +238,6 @@ pub(super) struct Bindings { /// A list of live bindings for this place, sorted by their `ScopedDefinitionId` #[expect(clippy::struct_field_names)] live_bindings: SmallVec<[LiveBinding; 2]>, - /// Latest place version seen for this place. - latest_place_version: PlaceVersion, } impl Bindings { @@ -277,7 +275,6 @@ impl Bindings { Self { unbound_narrowing_constraint: None, live_bindings: smallvec![initial_binding], - latest_place_version: PlaceVersion::default(), } } @@ -289,6 +286,7 @@ impl Bindings { is_class_scope: bool, is_place_name: bool, previous_definitions: PreviousDefinitions, + place_version: PlaceVersion, ) -> PlaceVersion { // If we are in a class scope, and the unbound name binding was previously visible, but we will // now replace it, record the narrowing constraints on it: @@ -299,9 +297,7 @@ impl Bindings { // constraints. if previous_definitions.are_shadowed() { self.live_bindings.clear(); - self.latest_place_version = self.latest_place_version.next(); } - let place_version = self.latest_place_version; self.live_bindings.push(LiveBinding { binding, narrowing_constraint: ScopedNarrowingConstraint::ALWAYS_TRUE, @@ -345,7 +341,6 @@ impl Bindings { reachability_constraints: &mut ReachabilityConstraintsBuilder, ) { let a = std::mem::take(self); - self.latest_place_version = a.latest_place_version.max(b.latest_place_version); if let Some((a, b)) = a .unbound_narrowing_constraint @@ -404,17 +399,19 @@ impl Bindings { } #[derive(Clone, Debug, PartialEq, Eq, get_size2::GetSize)] -pub(in crate::semantic_index) struct PlaceState { +pub(in crate::semantic_index) struct BuilderPlaceState { declarations: Declarations, bindings: Bindings, + latest_place_version: PlaceVersion, } -impl PlaceState { - /// Return a new [`PlaceState`] representing an unbound, undeclared place. +impl BuilderPlaceState { + /// Return a new [`BuilderPlaceState`] representing an unbound, undeclared place. pub(super) fn undefined(reachability: ScopedReachabilityConstraintId) -> Self { Self { declarations: Declarations::undeclared(reachability), bindings: Bindings::unbound(reachability), + latest_place_version: PlaceVersion::default(), } } @@ -428,12 +425,17 @@ impl PlaceState { previous_definitions: PreviousDefinitions, ) -> PlaceVersion { debug_assert_ne!(binding_id, ScopedDefinitionId::UNBOUND); + if previous_definitions.are_shadowed() { + self.latest_place_version = self.latest_place_version.next(); + } + self.bindings.record_binding( binding_id, reachability_constraint, is_class_scope, is_place_name, previous_definitions, + self.latest_place_version, ) } @@ -472,12 +474,13 @@ impl PlaceState { ); } - /// Merge another [`PlaceState`] into this one. + /// Merge another [`BuilderPlaceState`] into this one. pub(super) fn merge( &mut self, - b: PlaceState, + b: BuilderPlaceState, reachability_constraints: &mut ReachabilityConstraintsBuilder, ) { + self.latest_place_version = self.latest_place_version.max(b.latest_place_version); self.bindings.merge(b.bindings, reachability_constraints); self.declarations .merge(b.declarations, reachability_constraints); @@ -495,6 +498,29 @@ impl PlaceState { self.declarations.finish(reachability_constraints); self.bindings.finish(reachability_constraints); } + + pub(super) fn into_place_state(self) -> PlaceState { + PlaceState { + declarations: self.declarations, + bindings: self.bindings, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, get_size2::GetSize)] +pub(in crate::semantic_index) struct PlaceState { + declarations: Declarations, + bindings: Bindings, +} + +impl PlaceState { + pub(super) fn bindings(&self) -> &Bindings { + &self.bindings + } + + pub(super) fn declarations(&self) -> &Declarations { + &self.declarations + } } #[cfg(test)] @@ -505,7 +531,7 @@ mod tests { use crate::semantic_index::predicate::ScopedPredicateId; #[track_caller] - fn assert_bindings(place: &PlaceState, expected: &[(u32, ScopedNarrowingConstraint)]) { + fn assert_bindings(place: &BuilderPlaceState, expected: &[(u32, ScopedNarrowingConstraint)]) { let actual: Vec<(u32, ScopedNarrowingConstraint)> = place .bindings() .iter() @@ -520,7 +546,7 @@ mod tests { } #[track_caller] - pub(crate) fn assert_declarations(place: &PlaceState, expected: &[&str]) { + pub(crate) fn assert_declarations(place: &BuilderPlaceState, expected: &[&str]) { let actual = place .declarations() .iter() @@ -542,14 +568,14 @@ mod tests { #[test] fn unbound() { - let sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); assert_bindings(&sym, &[(0, ScopedNarrowingConstraint::ALWAYS_TRUE)]); } #[test] fn with() { - let mut sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.record_binding( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -564,7 +590,7 @@ mod tests { #[test] fn record_constraint() { let mut reachability_constraints = ReachabilityConstraintsBuilder::default(); - let mut sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.record_binding( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -583,7 +609,7 @@ mod tests { let mut reachability_constraints = ReachabilityConstraintsBuilder::default(); // merging the same definition with the same constraint keeps the constraint - let mut sym1a = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym1a = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym1a.record_binding( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -594,7 +620,7 @@ mod tests { let atom0 = reachability_constraints.add_atom(ScopedPredicateId::new(0)); sym1a.record_narrowing_constraint(&mut reachability_constraints, atom0); - let mut sym1b = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym1b = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym1b.record_binding( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -610,7 +636,7 @@ mod tests { assert_bindings(&sym1, &[(1, atom0)]); // merging the same definition with differing constraints produces OR (not empty) - let mut sym2a = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym2a = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym2a.record_binding( ScopedDefinitionId::from_u32(2), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -621,7 +647,7 @@ mod tests { let atom1 = reachability_constraints.add_atom(ScopedPredicateId::new(1)); sym2a.record_narrowing_constraint(&mut reachability_constraints, atom1); - let mut sym1b = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym1b = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym1b.record_binding( ScopedDefinitionId::from_u32(2), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -642,7 +668,7 @@ mod tests { assert_ne!(merged_constraint, atom2); // merging a constrained definition with unbound keeps both - let mut sym3a = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym3a = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym3a.record_binding( ScopedDefinitionId::from_u32(3), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -653,7 +679,7 @@ mod tests { let atom3 = reachability_constraints.add_atom(ScopedPredicateId::new(3)); sym3a.record_narrowing_constraint(&mut reachability_constraints, atom3); - let sym2b = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let sym2b = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym3a.merge(sym2b, &mut reachability_constraints); let sym3 = sym3a; @@ -683,7 +709,7 @@ mod tests { assert_eq!(bindings[2].1, atom3); // An unreachable branch should not dilute narrowing from the reachable branch. - let mut sym4a = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym4a = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym4a.record_binding( ScopedDefinitionId::from_u32(4), ScopedReachabilityConstraintId::ALWAYS_FALSE, @@ -692,7 +718,7 @@ mod tests { PreviousDefinitions::AreShadowed, ); - let mut sym4b = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym4b = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym4b.record_binding( ScopedDefinitionId::from_u32(4), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -710,14 +736,14 @@ mod tests { #[test] fn no_declaration() { - let sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); assert_declarations(&sym, &["undeclared"]); } #[test] fn record_declaration() { - let mut sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.record_declaration( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -728,7 +754,7 @@ mod tests { #[test] fn record_declaration_override() { - let mut sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.record_declaration( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -744,13 +770,13 @@ mod tests { #[test] fn record_declaration_merge() { let mut reachability_constraints = ReachabilityConstraintsBuilder::default(); - let mut sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.record_declaration( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, ); - let mut sym2 = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym2 = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym2.record_declaration( ScopedDefinitionId::from_u32(2), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -764,13 +790,13 @@ mod tests { #[test] fn record_declaration_merge_partial_undeclared() { let mut reachability_constraints = ReachabilityConstraintsBuilder::default(); - let mut sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.record_declaration( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, ); - let sym2 = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let sym2 = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.merge(sym2, &mut reachability_constraints); From b0add5e40f2e1740b46ed1f7665b57bfd669426c Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sun, 15 Feb 2026 18:34:46 +0900 Subject: [PATCH 26/69] intern `bindings_by_use` --- .../src/semantic_index/use_def.rs | 54 ++++++++++++++++--- .../src/semantic_index/use_def/place_state.rs | 5 +- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index b33f20f2870ccb..a2f133e79c3fdd 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -160,8 +160,9 @@ //! complete module, not a partially-executed module. (We may want to get a little smarter than //! this in the future for some closures, but for now this is where we start.) //! -//! The data structure we build to answer these questions is the `UseDefMap`. It has a -//! `bindings_by_use` vector of [`Bindings`] indexed by [`ScopedUseId`], a +//! The data structure we build to answer these questions is the `UseDefMap`. It has an interned +//! bindings table plus a `bindings_by_use` vector of interned bindings IDs indexed by +//! [`ScopedUseId`], a //! `declarations_by_binding` vector of [`Declarations`] indexed by [`ScopedDefinitionId`], a //! `bindings_by_declaration` vector of [`Bindings`] indexed by [`ScopedDefinitionId`], and //! `public_bindings` and `public_definitions` vectors indexed by [`ScopedPlaceId`]. The values in @@ -282,6 +283,11 @@ pub(crate) struct PredicatePlaceVersionInfo { pub(crate) type PredicatePlaceVersions = FxHashMap<(ScopedPredicateId, ScopedPlaceId), PredicatePlaceVersionInfo>; +/// Uniquely identifies an interned [`Bindings`] entry in [`UseDefMap::interned_bindings`]. +#[newtype_index] +#[derive(get_size2::GetSize)] +struct ScopedBindingsId; + /// Applicable definitions and constraints for every use of a name. #[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] pub(crate) struct UseDefMap<'db> { @@ -304,8 +310,11 @@ pub(crate) struct UseDefMap<'db> { /// Array of reachability constraints in this scope. reachability_constraints: ReachabilityConstraints, - /// [`Bindings`] reaching a [`ScopedUseId`]. - bindings_by_use: IndexVec, + /// Interned [`Bindings`] values referenced by [`Self::bindings_by_use`]. + interned_bindings: IndexVec, + + /// Interned bindings ID reaching a [`ScopedUseId`]. + bindings_by_use: IndexVec, /// Tracks whether or not a given AST node is reachable from the start of the scope. node_reachability: FxHashMap, @@ -376,8 +385,9 @@ impl<'db> UseDefMap<'db> { &self, use_id: ScopedUseId, ) -> BindingWithConstraintsIterator<'_, 'db> { + let bindings_id = self.bindings_by_use[use_id]; self.bindings_iterator( - &self.bindings_by_use[use_id], + &self.interned_bindings[bindings_id], BoundnessAnalysis::BasedOnUnboundVisibility, ) } @@ -1619,6 +1629,9 @@ impl<'db> UseDefMapBuilder<'db> { self.bindings_by_definition.shrink_to_fit(); self.enclosing_snapshots.shrink_to_fit(); + let (interned_bindings, bindings_by_use) = + Self::intern_bindings_by_use(self.bindings_by_use); + let end_of_scope_symbols: IndexVec = self .symbol_states .into_iter() @@ -1636,7 +1649,8 @@ impl<'db> UseDefMapBuilder<'db> { definition_place_versions: self.definition_place_versions, predicate_place_versions: self.predicate_place_versions, reachability_constraints: self.reachability_constraints.build(), - bindings_by_use: self.bindings_by_use, + interned_bindings, + bindings_by_use, node_reachability: self.node_reachability, end_of_scope_symbols, end_of_scope_members, @@ -1648,4 +1662,32 @@ impl<'db> UseDefMapBuilder<'db> { end_of_scope_reachability: self.reachability, } } + + fn intern_bindings_by_use( + bindings_by_use: IndexVec, + ) -> ( + IndexVec, + IndexVec, + ) { + let mut interned_bindings: IndexVec = IndexVec::new(); + let mut interned_ids_by_use: IndexVec = IndexVec::new(); + let mut interned_ids_by_bindings: FxHashMap = + FxHashMap::default(); + + for bindings in bindings_by_use { + let interned_id = if let Some(interned_id) = interned_ids_by_bindings.get(&bindings) { + *interned_id + } else { + let interned_id = interned_bindings.push(bindings.clone()); + interned_ids_by_bindings.insert(bindings, interned_id); + interned_id + }; + interned_ids_by_use.push(interned_id); + } + + interned_bindings.shrink_to_fit(); + interned_ids_by_use.shrink_to_fit(); + + (interned_bindings, interned_ids_by_use) + } } diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index 80d771caa3c444..71613f18c597cb 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -228,7 +228,7 @@ impl EnclosingSnapshot { /// Live bindings for a single place at some point in control flow. Each live binding comes /// with a set of narrowing constraints and a reachability constraint. -#[derive(Clone, Debug, Default, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] pub(super) struct Bindings { /// The narrowing constraint applicable to the "unbound" binding, if we need access to it even /// when it's not visible. This happens in class scopes, where local name bindings are not visible @@ -236,7 +236,6 @@ pub(super) struct Bindings { /// "unbound" binding. unbound_narrowing_constraint: Option, /// A list of live bindings for this place, sorted by their `ScopedDefinitionId` - #[expect(clippy::struct_field_names)] live_bindings: SmallVec<[LiveBinding; 2]>, } @@ -256,7 +255,7 @@ impl Bindings { } /// One of the live bindings for a single place at some point in control flow. -#[derive(Clone, Copy, Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] pub(crate) struct LiveBinding { pub(crate) binding: ScopedDefinitionId, pub(crate) narrowing_constraint: ScopedNarrowingConstraint, From fd02b2b6fa13496c84b7112d51fb3a704d1ff12d Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Mon, 16 Feb 2026 18:48:12 +0900 Subject: [PATCH 27/69] Revert "intern `bindings_by_use`" This reverts commit b0add5e40f2e1740b46ed1f7665b57bfd669426c. --- .../src/semantic_index/use_def.rs | 54 +++---------------- .../src/semantic_index/use_def/place_state.rs | 5 +- 2 files changed, 9 insertions(+), 50 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index a2f133e79c3fdd..b33f20f2870ccb 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -160,9 +160,8 @@ //! complete module, not a partially-executed module. (We may want to get a little smarter than //! this in the future for some closures, but for now this is where we start.) //! -//! The data structure we build to answer these questions is the `UseDefMap`. It has an interned -//! bindings table plus a `bindings_by_use` vector of interned bindings IDs indexed by -//! [`ScopedUseId`], a +//! The data structure we build to answer these questions is the `UseDefMap`. It has a +//! `bindings_by_use` vector of [`Bindings`] indexed by [`ScopedUseId`], a //! `declarations_by_binding` vector of [`Declarations`] indexed by [`ScopedDefinitionId`], a //! `bindings_by_declaration` vector of [`Bindings`] indexed by [`ScopedDefinitionId`], and //! `public_bindings` and `public_definitions` vectors indexed by [`ScopedPlaceId`]. The values in @@ -283,11 +282,6 @@ pub(crate) struct PredicatePlaceVersionInfo { pub(crate) type PredicatePlaceVersions = FxHashMap<(ScopedPredicateId, ScopedPlaceId), PredicatePlaceVersionInfo>; -/// Uniquely identifies an interned [`Bindings`] entry in [`UseDefMap::interned_bindings`]. -#[newtype_index] -#[derive(get_size2::GetSize)] -struct ScopedBindingsId; - /// Applicable definitions and constraints for every use of a name. #[derive(Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] pub(crate) struct UseDefMap<'db> { @@ -310,11 +304,8 @@ pub(crate) struct UseDefMap<'db> { /// Array of reachability constraints in this scope. reachability_constraints: ReachabilityConstraints, - /// Interned [`Bindings`] values referenced by [`Self::bindings_by_use`]. - interned_bindings: IndexVec, - - /// Interned bindings ID reaching a [`ScopedUseId`]. - bindings_by_use: IndexVec, + /// [`Bindings`] reaching a [`ScopedUseId`]. + bindings_by_use: IndexVec, /// Tracks whether or not a given AST node is reachable from the start of the scope. node_reachability: FxHashMap, @@ -385,9 +376,8 @@ impl<'db> UseDefMap<'db> { &self, use_id: ScopedUseId, ) -> BindingWithConstraintsIterator<'_, 'db> { - let bindings_id = self.bindings_by_use[use_id]; self.bindings_iterator( - &self.interned_bindings[bindings_id], + &self.bindings_by_use[use_id], BoundnessAnalysis::BasedOnUnboundVisibility, ) } @@ -1629,9 +1619,6 @@ impl<'db> UseDefMapBuilder<'db> { self.bindings_by_definition.shrink_to_fit(); self.enclosing_snapshots.shrink_to_fit(); - let (interned_bindings, bindings_by_use) = - Self::intern_bindings_by_use(self.bindings_by_use); - let end_of_scope_symbols: IndexVec = self .symbol_states .into_iter() @@ -1649,8 +1636,7 @@ impl<'db> UseDefMapBuilder<'db> { definition_place_versions: self.definition_place_versions, predicate_place_versions: self.predicate_place_versions, reachability_constraints: self.reachability_constraints.build(), - interned_bindings, - bindings_by_use, + bindings_by_use: self.bindings_by_use, node_reachability: self.node_reachability, end_of_scope_symbols, end_of_scope_members, @@ -1662,32 +1648,4 @@ impl<'db> UseDefMapBuilder<'db> { end_of_scope_reachability: self.reachability, } } - - fn intern_bindings_by_use( - bindings_by_use: IndexVec, - ) -> ( - IndexVec, - IndexVec, - ) { - let mut interned_bindings: IndexVec = IndexVec::new(); - let mut interned_ids_by_use: IndexVec = IndexVec::new(); - let mut interned_ids_by_bindings: FxHashMap = - FxHashMap::default(); - - for bindings in bindings_by_use { - let interned_id = if let Some(interned_id) = interned_ids_by_bindings.get(&bindings) { - *interned_id - } else { - let interned_id = interned_bindings.push(bindings.clone()); - interned_ids_by_bindings.insert(bindings, interned_id); - interned_id - }; - interned_ids_by_use.push(interned_id); - } - - interned_bindings.shrink_to_fit(); - interned_ids_by_use.shrink_to_fit(); - - (interned_bindings, interned_ids_by_use) - } } diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index 71613f18c597cb..80d771caa3c444 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -228,7 +228,7 @@ impl EnclosingSnapshot { /// Live bindings for a single place at some point in control flow. Each live binding comes /// with a set of narrowing constraints and a reachability constraint. -#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, salsa::Update, get_size2::GetSize)] pub(super) struct Bindings { /// The narrowing constraint applicable to the "unbound" binding, if we need access to it even /// when it's not visible. This happens in class scopes, where local name bindings are not visible @@ -236,6 +236,7 @@ pub(super) struct Bindings { /// "unbound" binding. unbound_narrowing_constraint: Option, /// A list of live bindings for this place, sorted by their `ScopedDefinitionId` + #[expect(clippy::struct_field_names)] live_bindings: SmallVec<[LiveBinding; 2]>, } @@ -255,7 +256,7 @@ impl Bindings { } /// One of the live bindings for a single place at some point in control flow. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, salsa::Update, get_size2::GetSize)] pub(crate) struct LiveBinding { pub(crate) binding: ScopedDefinitionId, pub(crate) narrowing_constraint: ScopedNarrowingConstraint, From 2938e78c131df6d5fd814104e44b1a980022e26a Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Mon, 16 Feb 2026 23:25:52 +0900 Subject: [PATCH 28/69] follow review --- .../src/semantic_index/builder.rs | 68 +++++++++++++------ .../src/semantic_index/use_def/place_state.rs | 1 - 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index bdcd224c981cfe..85fbe48c73e1f7 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -912,6 +912,25 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { } fn build_predicate(&mut self, predicate_node: &ast::Expr) -> PredicateOrLiteral<'db> { + /// Returns if the expression is a `TYPE_CHECKING` expression. + fn is_if_type_checking(expr: &ast::Expr) -> bool { + fn is_dotted_name(expr: &ast::Expr) -> bool { + match expr { + ast::Expr::Name(_) => true, + ast::Expr::Attribute(ast::ExprAttribute { value, .. }) => is_dotted_name(value), + _ => false, + } + } + + match expr { + ast::Expr::Name(ast::ExprName { id, .. }) => id == "TYPE_CHECKING", + ast::Expr::Attribute(ast::ExprAttribute { value, attr, .. }) => { + attr == "TYPE_CHECKING" && is_dotted_name(value) + } + _ => false, + } + } + // Some commonly used test expressions are eagerly evaluated as `true` // or `false` here for performance reasons. This list does not need to // be exhaustive. More complex expressions will still evaluate to the @@ -949,15 +968,13 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { ast::Expr::BooleanLiteral(ast::ExprBooleanLiteral { value, .. }) => { Some(ConstExpr::Bool(*value)) } - ast::Expr::Name(ast::ExprName { id, .. }) if id == "TYPE_CHECKING" => { - Some(ConstExpr::Bool(true)) - } ast::Expr::NumberLiteral(ast::ExprNumberLiteral { value: ast::Number::Int(n), .. }) => n.as_i64().map(ConstExpr::Int), ast::Expr::EllipsisLiteral(_) => Some(ConstExpr::Ellipsis), ast::Expr::NoneLiteral(_) => Some(ConstExpr::None), + // See also: `TypeInferenceBuilder::infer_unary_expression_type` ast::Expr::UnaryOp(ast::ExprUnaryOp { op, operand, .. }) => { let operand = resolve_const_expr(operand)?; match op { @@ -969,6 +986,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { ast::UnaryOp::Invert => Some(ConstExpr::Int(!operand.as_int()?)), } } + // See also: `TypeInferenceBuilder::infer_binary_expression_type` ast::Expr::BinOp(ast::ExprBinOp { left, op, right, .. }) => { @@ -979,16 +997,26 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { ast::Operator::Sub => left.checked_sub(right)?, ast::Operator::Mult => left.checked_mul(right)?, ast::Operator::FloorDiv => { - if right == 0 { - return None; + let mut q = left.checked_div(right); + let r = left.checked_rem(right); + // Division works differently in Python than in Rust. If the + // result is negative and there is a remainder, floor division + // rounds down (instead of toward zero). + if left.is_negative() != right.is_negative() && r.unwrap_or(0) != 0 + { + q = q.map(|q| q - 1); } - left.div_euclid(right) + q? } ast::Operator::Mod => { - if right == 0 { - return None; + let mut r = left.checked_rem(right); + // Python's modulo keeps the sign of the divisor. Adjust the Rust + // remainder accordingly so that `q * right + r == left`. + if left.is_negative() != right.is_negative() && r.unwrap_or(0) != 0 + { + r = r.map(|x| x + right); } - left.rem_euclid(right) + r? } ast::Operator::BitAnd => left & right, ast::Operator::BitOr => left | right, @@ -1043,18 +1071,15 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { let mut left_value = resolve_const_expr(left)?; for (op, comparator) in ops.iter().zip(comparators.iter()) { let right_value = resolve_const_expr(comparator)?; - let eq = |left: ConstExpr, right: ConstExpr| match ( - left.as_int(), - right.as_int(), - ) { - (Some(left), Some(right)) => Some(left == right), - _ => match (left, right) { - (ConstExpr::None, ConstExpr::None) - | (ConstExpr::Ellipsis, ConstExpr::Ellipsis) => Some(true), - (ConstExpr::None | ConstExpr::Ellipsis, _) - | (_, ConstExpr::None | ConstExpr::Ellipsis) => Some(false), - _ => None, - }, + let eq = |left: ConstExpr, right: ConstExpr| match (left, right) { + (ConstExpr::Int(left), ConstExpr::Int(right)) => { + Some(left == right) + } + (ConstExpr::None, ConstExpr::None) + | (ConstExpr::Ellipsis, ConstExpr::Ellipsis) => Some(true), + (ConstExpr::None | ConstExpr::Ellipsis, _) + | (_, ConstExpr::None | ConstExpr::Ellipsis) => Some(false), + _ => None, }; let result = match op { ast::CmpOp::Eq => eq(left_value, right_value)?, @@ -1102,6 +1127,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { } Some(ConstExpr::Bool(true)) } + _ if is_if_type_checking(node) => Some(ConstExpr::Bool(true)), _ => None, } } diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index 80d771caa3c444..1d85303a3430ec 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -236,7 +236,6 @@ pub(super) struct Bindings { /// "unbound" binding. unbound_narrowing_constraint: Option, /// A list of live bindings for this place, sorted by their `ScopedDefinitionId` - #[expect(clippy::struct_field_names)] live_bindings: SmallVec<[LiveBinding; 2]>, } From 432e7d4927a9849490ac2f5ad3c32ff94dfe20bc Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Tue, 17 Feb 2026 01:03:36 +0900 Subject: [PATCH 29/69] Update narrow.rs --- crates/ty_python_semantic/src/types/narrow.rs | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 7922f33a7c25cc..b7e1fbd8068a16 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1355,13 +1355,13 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { // If this is `None`, it indicates that we cannot do `if type(x) is Y` // narrowing: we can only do narrowing for `if type(x) is Y` and // `if type(x) is not Y`, not for `if type(x) == Y` or `if type(x) != Y`. - let type_narrowing_is_positive = match op { + let is_positive = match op { ast::CmpOp::Is => Some(is_positive), ast::CmpOp::IsNot => Some(!is_positive), _ => None, }; - if let Some(type_narrowing_is_positive) = type_narrowing_is_positive + if let Some(is_positive) = is_positive && keywords.is_empty() && let [single_argument] = &**args && let Some(target) = PlaceExpr::try_from_expr(single_argument) @@ -1370,14 +1370,14 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { && let Some(other_class) = find_underlying_class(self.db, other) // `else`-branch narrowing for `if type(x) is Y` can only be done // if `Y` is a final class - && (type_narrowing_is_positive || other_class.is_final(self.db)) + && (is_positive || other_class.is_final(self.db)) { let place = self.expect_place(&target); constraints.insert( place, NarrowingConstraint::intersection( Type::instance(self.db, other_class.top_materialization(self.db)) - .negate_if(self.db, !type_narrowing_is_positive), + .negate_if(self.db, !is_positive), ), ); last_rhs_ty = Some(rhs_ty); @@ -1451,14 +1451,15 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { ) -> DualNarrowingConstraints<'db> { let inference = infer_expression_types(self.db, expression, TypeContext::default()); - if let Some(positive_type_guard_call_constraints) = + // If the return type of expr_call is TypeGuard (positive) / TypeIs: + if let Some(positive_constraints) = self.evaluate_type_guard_call_for_polarity(inference, expr_call, true) { - let negative_type_guard_call_constraints = + let negative_constraints = self.evaluate_type_guard_call_for_polarity(inference, expr_call, false); return DualNarrowingConstraints::from_sides( - Some(positive_type_guard_call_constraints), - negative_type_guard_call_constraints, + Some(positive_constraints), + negative_constraints, ); } @@ -1487,17 +1488,6 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { is_positive: bool, ) -> Option> { match callable_ty { - Type::FunctionLiteral(function_type) - if matches!( - function_type.known(self.db), - None | Some(KnownFunction::RevealType) - ) => - { - self.evaluate_type_guard_call_for_polarity(inference, expr_call, is_positive) - } - Type::BoundMethod(_) => { - self.evaluate_type_guard_call_for_polarity(inference, expr_call, is_positive) - } // For the expression `len(E)`, we narrow the type based on whether len(E) is truthy // (i.e., whether E is non-empty). We only narrow the parts of the type where we know // `__bool__` and `__len__` are consistent (literals, tuples). Non-narrowable parts @@ -1573,7 +1563,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { } // Helper to evaluate TypeGuard/TypeIs narrowing for a call expression. - // Used for both direct function calls and bound method calls. + // This is based on the call expression's return type, so it applies to any callable type. fn evaluate_type_guard_call_for_polarity( &mut self, inference: &ExpressionInference<'db>, From 8bff224d37cf39764c577bb39b04ea41c00891e5 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Tue, 17 Feb 2026 01:18:55 +0900 Subject: [PATCH 30/69] fix `L/RShift` implemenation in `resolve_to_literal` --- .../src/semantic_index/builder.rs | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index 85fbe48c73e1f7..ca7b71ac29a025 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -1022,13 +1022,39 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { ast::Operator::BitOr => left | right, ast::Operator::BitXor => left ^ right, ast::Operator::LShift => { - let shift = u32::try_from(right).ok()?; - left.checked_shl(shift)? - } - ast::Operator::RShift => { - let shift = u32::try_from(right).ok()?; - left.checked_shr(shift)? + if left == 0 && right >= 0 { + 0 + } else { + // An additional overflow check beyond `checked_shl` is + // necessary here, because `checked_shl` only rejects shift + // amounts >= 64; it does not detect when significant bits + // are shifted into (or past) the sign bit. + // + // We compute the "headroom": the number of redundant + // sign-extension bits minus one (for the sign bit itself). + // A shift is safe iff `shift <= headroom`. + let headroom = if left >= 0 { + left.leading_zeros().saturating_sub(1) + } else { + left.leading_ones().saturating_sub(1) + }; + u32::try_from(right) + .ok() + .filter(|&shift| shift <= headroom) + .and_then(|shift| left.checked_shl(shift))? + } } + ast::Operator::RShift => match u32::try_from(right) { + Ok(shift) => left >> shift.clamp(0, 63), + Err(_) if right > 0 => { + if left >= 0 { + 0 + } else { + -1 + } + } + Err(_) => return None, + }, ast::Operator::Pow => { let exp = u32::try_from(right).ok()?; left.checked_pow(exp)? From a3eb0ab2fb295f8d01f54d8573ecfdc014cd71ce Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Tue, 17 Feb 2026 02:11:26 +0900 Subject: [PATCH 31/69] add unit tests for `resolve_to_literal` --- .../resources/mdtest/binary/integers.md | 6 + .../ty_python_semantic/src/semantic_index.rs | 109 ++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/binary/integers.md b/crates/ty_python_semantic/resources/mdtest/binary/integers.md index 30561981810b32..3834a90e0ba20e 100644 --- a/crates/ty_python_semantic/resources/mdtest/binary/integers.md +++ b/crates/ty_python_semantic/resources/mdtest/binary/integers.md @@ -1,5 +1,11 @@ # Binary operations on integers +> Developer's note: This is mainly a test for the behavior of the type inferer. The constant +> evaluator (`resolve_to_literal`) of `SemanticIndexBuilder` is implemented separately from the type +> inferer, so if you modify the contents of this file or the type inferer, please also modify the +> implementation of `resolve_to_literal` and the unit tests (semantic_index/tests/const_eval\_\*) at +> the same time. + ## Basic Arithmetic ```py diff --git a/crates/ty_python_semantic/src/semantic_index.rs b/crates/ty_python_semantic/src/semantic_index.rs index e49d3188104ec8..d64bde0cef810f 100644 --- a/crates/ty_python_semantic/src/semantic_index.rs +++ b/crates/ty_python_semantic/src/semantic_index.rs @@ -925,6 +925,50 @@ mod tests { .collect() } + /// A function to test how the constant evaluator of `SemanticIndexBuilder` evaluates an expression + /// (the evaluation should match that of `TypeInferenceBuilder`). + /// For example, for the input `x = 1\nif cond: x = 2\nx`, if `cond` evaluates to `AlwaysTrue`, it returns `vec![2]`, + /// if it evaluates to `AlwaysFalse`, it returns `vec![1]`, ​​if it evaluates to `Ambiguous`, it returns `vec![1, 2]`. + fn reachable_bindings_for_terminal_use(content: &str) -> Vec { + let TestCase { db, file } = test_case(content); + let scope = global_scope(&db, file); + let module = parsed_module(&db, file).load(&db); + let ast = module.syntax(); + + let terminal_expr = ast + .body + .last() + .and_then(ast::Stmt::as_expr_stmt) + .map(|stmt| stmt.value.as_ref()) + .expect("expected terminal expression statement"); + let terminal_name = terminal_expr + .as_name_expr() + .expect("terminal expression should be a name"); + + let use_id = terminal_name.scoped_use_id(&db, scope); + let use_def = use_def_map(&db, scope); + + use_def + .bindings_at_use(use_id) + .filter_map(|binding_with_constraints| { + let definition = binding_with_constraints.binding.definition()?; + let DefinitionKind::Assignment(assignment) = definition.kind(&db) else { + return None; + }; + + let ast::Expr::NumberLiteral(ast::ExprNumberLiteral { + value: ast::Number::Int(value), + .. + }) = assignment.value(&module) + else { + return None; + }; + + value.as_i64() + }) + .collect::>() + } + #[test] fn empty() { let TestCase { db, file } = test_case(""); @@ -1590,6 +1634,71 @@ class C[T]: assert_eq!(*num, 1); } + #[test] + fn const_eval_lshift_overflow_is_ambiguous() { + let values = reachable_bindings_for_terminal_use( + " +x = 1 +if 1 << 63: + x = 2 +x +", + ); + assert_eq!(values, vec![1, 2]); + } + + #[test] + fn const_eval_lshift_zero_short_circuit() { + let values = reachable_bindings_for_terminal_use( + " +x = 1 +if 0 << 4000000000000000000: + x = 2 +x +", + ); + assert_eq!(values, vec![1]); + } + + #[test] + fn const_eval_rshift_large_positive() { + let values = reachable_bindings_for_terminal_use( + " +x = 1 +if 1 >> 5000000000: + x = 2 +x +", + ); + assert_eq!(values, vec![1]); + } + + #[test] + fn const_eval_rshift_large_negative_operand() { + let values = reachable_bindings_for_terminal_use( + " +x = 1 +if (-1) >> 5000000000: + x = 2 +x +", + ); + assert_eq!(values, vec![2]); + } + + #[test] + fn const_eval_negative_lshift_is_ambiguous() { + let values = reachable_bindings_for_terminal_use( + " +x = 1 +if 42 << -3: + x = 2 +x +", + ); + assert_eq!(values, vec![1, 2]); + } + #[test] fn expression_scope() { let TestCase { db, file } = test_case("x = 1;\ndef test():\n y = 4"); From 04dfb49c2649c548afc86ecd7a481c73add534fa Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 01:06:57 +0900 Subject: [PATCH 32/69] Revert "remove `latest_place_version` from `Bindings`" This reverts commit 9142acdc73255d0b71c11c74e202e765c2d3f1a7. --- .../src/semantic_index/use_def.rs | 59 +++++------- .../src/semantic_index/use_def/place_state.rs | 90 +++++++------------ 2 files changed, 54 insertions(+), 95 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index b33f20f2870ccb..66d1fb3848e347 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -261,8 +261,8 @@ use crate::semantic_index::reachability_constraints::{ use crate::semantic_index::scope::{FileScopeId, ScopeKind, ScopeLaziness}; use crate::semantic_index::symbol::ScopedSymbolId; use crate::semantic_index::use_def::place_state::{ - Bindings, BuilderPlaceState, Declarations, EnclosingSnapshot, LiveBindingsIterator, - LiveDeclaration, LiveDeclarationsIterator, PlaceState, + Bindings, Declarations, EnclosingSnapshot, LiveBindingsIterator, LiveDeclaration, + LiveDeclarationsIterator, PlaceState, }; use crate::semantic_index::{EnclosingSnapshotResult, SemanticIndex}; use crate::types::{PossiblyNarrowedPlaces, Truthiness, Type}; @@ -849,16 +849,16 @@ struct ReachableDefinitions { /// A snapshot of the definitions and constraints state at a particular point in control flow. #[derive(Clone, Debug)] pub(super) struct FlowSnapshot { - symbol_states: IndexVec, - member_states: IndexVec, + symbol_states: IndexVec, + member_states: IndexVec, reachability: ScopedReachabilityConstraintId, } /// A snapshot of the state of a single symbol (e.g. `obj`) and all of its associated members /// (e.g. `obj.attr`, `obj["key"]`). pub(super) struct SingleSymbolSnapshot { - symbol_state: BuilderPlaceState, - associated_member_states: FxHashMap, + symbol_state: PlaceState, + associated_member_states: FxHashMap, } #[derive(Debug)] @@ -895,9 +895,9 @@ pub(super) struct UseDefMapBuilder<'db> { bindings_by_definition: FxHashMap, Bindings>, /// Currently live bindings and declarations for each place. - symbol_states: IndexVec, + symbol_states: IndexVec, - member_states: IndexVec, + member_states: IndexVec, /// All potentially reachable bindings and declarations, for each place. reachable_symbol_definitions: IndexVec, @@ -957,7 +957,7 @@ impl<'db> UseDefMapBuilder<'db> { ScopedPlaceId::Symbol(symbol) => { let new_place = self .symbol_states - .push(BuilderPlaceState::undefined(self.reachability)); + .push(PlaceState::undefined(self.reachability)); debug_assert_eq!(symbol, new_place); let new_place = self .reachable_symbol_definitions @@ -970,7 +970,7 @@ impl<'db> UseDefMapBuilder<'db> { ScopedPlaceId::Member(member) => { let new_place = self .member_states - .push(BuilderPlaceState::undefined(self.reachability)); + .push(PlaceState::undefined(self.reachability)); debug_assert_eq!(member, new_place); let new_place = self .reachable_member_definitions @@ -1030,7 +1030,6 @@ impl<'db> UseDefMapBuilder<'db> { self.is_class_scope, place.is_symbol(), PreviousDefinitions::AreKept, - place_version, ); } @@ -1098,14 +1097,12 @@ impl<'db> UseDefMapBuilder<'db> { ) { for place in places { let bindings = match place { - ScopedPlaceId::Symbol(symbol_id) => self - .symbol_states - .get(*symbol_id) - .map(BuilderPlaceState::bindings), - ScopedPlaceId::Member(member_id) => self - .member_states - .get(*member_id) - .map(BuilderPlaceState::bindings), + ScopedPlaceId::Symbol(symbol_id) => { + self.symbol_states.get(*symbol_id).map(PlaceState::bindings) + } + ScopedPlaceId::Member(member_id) => { + self.member_states.get(*member_id).map(PlaceState::bindings) + } }; let Some(bindings) = bindings else { continue; @@ -1364,7 +1361,6 @@ impl<'db> UseDefMapBuilder<'db> { self.is_class_scope, place.is_symbol(), PreviousDefinitions::AreKept, - place_version, ); } @@ -1498,10 +1494,10 @@ impl<'db> UseDefMapBuilder<'db> { // to fill them in so the place IDs continue to line up. Since they don't exist in the // snapshot, the correct state to fill them in with is "undefined". self.symbol_states - .resize(num_symbols, BuilderPlaceState::undefined(self.reachability)); + .resize(num_symbols, PlaceState::undefined(self.reachability)); self.member_states - .resize(num_members, BuilderPlaceState::undefined(self.reachability)); + .resize(num_members, PlaceState::undefined(self.reachability)); } /// Merge the given snapshot into the current state, reflecting that we might have taken either @@ -1535,7 +1531,7 @@ impl<'db> UseDefMapBuilder<'db> { current.merge(snapshot, &mut self.reachability_constraints); } else { current.merge( - BuilderPlaceState::undefined(snapshot.reachability), + PlaceState::undefined(snapshot.reachability), &mut self.reachability_constraints, ); // Place not present in snapshot, so it's unbound/undeclared from that path. @@ -1548,7 +1544,7 @@ impl<'db> UseDefMapBuilder<'db> { current.merge(snapshot, &mut self.reachability_constraints); } else { current.merge( - BuilderPlaceState::undefined(snapshot.reachability), + PlaceState::undefined(snapshot.reachability), &mut self.reachability_constraints, ); // Place not present in snapshot, so it's unbound/undeclared from that path. @@ -1619,17 +1615,6 @@ impl<'db> UseDefMapBuilder<'db> { self.bindings_by_definition.shrink_to_fit(); self.enclosing_snapshots.shrink_to_fit(); - let end_of_scope_symbols: IndexVec = self - .symbol_states - .into_iter() - .map(BuilderPlaceState::into_place_state) - .collect(); - let end_of_scope_members: IndexVec = self - .member_states - .into_iter() - .map(BuilderPlaceState::into_place_state) - .collect(); - UseDefMap { all_definitions: self.all_definitions, predicates: self.predicates.build(), @@ -1638,8 +1623,8 @@ impl<'db> UseDefMapBuilder<'db> { reachability_constraints: self.reachability_constraints.build(), bindings_by_use: self.bindings_by_use, node_reachability: self.node_reachability, - end_of_scope_symbols, - end_of_scope_members, + end_of_scope_symbols: self.symbol_states, + end_of_scope_members: self.member_states, reachable_definitions_by_symbol: self.reachable_symbol_definitions, reachable_definitions_by_member: self.reachable_member_definitions, declarations_by_binding: self.declarations_by_binding, diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index 1d85303a3430ec..a3ff20a3c15d09 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -236,7 +236,10 @@ pub(super) struct Bindings { /// "unbound" binding. unbound_narrowing_constraint: Option, /// A list of live bindings for this place, sorted by their `ScopedDefinitionId` + #[allow(clippy::struct_field_names)] live_bindings: SmallVec<[LiveBinding; 2]>, + /// Latest place version seen for this place. + latest_place_version: PlaceVersion, } impl Bindings { @@ -274,6 +277,7 @@ impl Bindings { Self { unbound_narrowing_constraint: None, live_bindings: smallvec![initial_binding], + latest_place_version: PlaceVersion::default(), } } @@ -285,7 +289,6 @@ impl Bindings { is_class_scope: bool, is_place_name: bool, previous_definitions: PreviousDefinitions, - place_version: PlaceVersion, ) -> PlaceVersion { // If we are in a class scope, and the unbound name binding was previously visible, but we will // now replace it, record the narrowing constraints on it: @@ -296,13 +299,14 @@ impl Bindings { // constraints. if previous_definitions.are_shadowed() { self.live_bindings.clear(); + self.latest_place_version = self.latest_place_version.next(); } self.live_bindings.push(LiveBinding { binding, narrowing_constraint: ScopedNarrowingConstraint::ALWAYS_TRUE, reachability_constraint, }); - place_version + self.latest_place_version } /// Add given constraint to all live bindings. @@ -340,6 +344,7 @@ impl Bindings { reachability_constraints: &mut ReachabilityConstraintsBuilder, ) { let a = std::mem::take(self); + self.latest_place_version = a.latest_place_version.max(b.latest_place_version); if let Some((a, b)) = a .unbound_narrowing_constraint @@ -398,19 +403,17 @@ impl Bindings { } #[derive(Clone, Debug, PartialEq, Eq, get_size2::GetSize)] -pub(in crate::semantic_index) struct BuilderPlaceState { +pub(in crate::semantic_index) struct PlaceState { declarations: Declarations, bindings: Bindings, - latest_place_version: PlaceVersion, } -impl BuilderPlaceState { - /// Return a new [`BuilderPlaceState`] representing an unbound, undeclared place. +impl PlaceState { + /// Return a new [`PlaceState`] representing an unbound, undeclared place. pub(super) fn undefined(reachability: ScopedReachabilityConstraintId) -> Self { Self { declarations: Declarations::undeclared(reachability), bindings: Bindings::unbound(reachability), - latest_place_version: PlaceVersion::default(), } } @@ -424,17 +427,12 @@ impl BuilderPlaceState { previous_definitions: PreviousDefinitions, ) -> PlaceVersion { debug_assert_ne!(binding_id, ScopedDefinitionId::UNBOUND); - if previous_definitions.are_shadowed() { - self.latest_place_version = self.latest_place_version.next(); - } - self.bindings.record_binding( binding_id, reachability_constraint, is_class_scope, is_place_name, previous_definitions, - self.latest_place_version, ) } @@ -473,13 +471,12 @@ impl BuilderPlaceState { ); } - /// Merge another [`BuilderPlaceState`] into this one. + /// Merge another [`PlaceState`] into this one. pub(super) fn merge( &mut self, - b: BuilderPlaceState, + b: PlaceState, reachability_constraints: &mut ReachabilityConstraintsBuilder, ) { - self.latest_place_version = self.latest_place_version.max(b.latest_place_version); self.bindings.merge(b.bindings, reachability_constraints); self.declarations .merge(b.declarations, reachability_constraints); @@ -497,29 +494,6 @@ impl BuilderPlaceState { self.declarations.finish(reachability_constraints); self.bindings.finish(reachability_constraints); } - - pub(super) fn into_place_state(self) -> PlaceState { - PlaceState { - declarations: self.declarations, - bindings: self.bindings, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq, get_size2::GetSize)] -pub(in crate::semantic_index) struct PlaceState { - declarations: Declarations, - bindings: Bindings, -} - -impl PlaceState { - pub(super) fn bindings(&self) -> &Bindings { - &self.bindings - } - - pub(super) fn declarations(&self) -> &Declarations { - &self.declarations - } } #[cfg(test)] @@ -530,7 +504,7 @@ mod tests { use crate::semantic_index::predicate::ScopedPredicateId; #[track_caller] - fn assert_bindings(place: &BuilderPlaceState, expected: &[(u32, ScopedNarrowingConstraint)]) { + fn assert_bindings(place: &PlaceState, expected: &[(u32, ScopedNarrowingConstraint)]) { let actual: Vec<(u32, ScopedNarrowingConstraint)> = place .bindings() .iter() @@ -545,7 +519,7 @@ mod tests { } #[track_caller] - pub(crate) fn assert_declarations(place: &BuilderPlaceState, expected: &[&str]) { + pub(crate) fn assert_declarations(place: &PlaceState, expected: &[&str]) { let actual = place .declarations() .iter() @@ -567,14 +541,14 @@ mod tests { #[test] fn unbound() { - let sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); assert_bindings(&sym, &[(0, ScopedNarrowingConstraint::ALWAYS_TRUE)]); } #[test] fn with() { - let mut sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.record_binding( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -589,7 +563,7 @@ mod tests { #[test] fn record_constraint() { let mut reachability_constraints = ReachabilityConstraintsBuilder::default(); - let mut sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.record_binding( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -608,7 +582,7 @@ mod tests { let mut reachability_constraints = ReachabilityConstraintsBuilder::default(); // merging the same definition with the same constraint keeps the constraint - let mut sym1a = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym1a = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym1a.record_binding( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -619,7 +593,7 @@ mod tests { let atom0 = reachability_constraints.add_atom(ScopedPredicateId::new(0)); sym1a.record_narrowing_constraint(&mut reachability_constraints, atom0); - let mut sym1b = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym1b = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym1b.record_binding( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -635,7 +609,7 @@ mod tests { assert_bindings(&sym1, &[(1, atom0)]); // merging the same definition with differing constraints produces OR (not empty) - let mut sym2a = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym2a = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym2a.record_binding( ScopedDefinitionId::from_u32(2), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -646,7 +620,7 @@ mod tests { let atom1 = reachability_constraints.add_atom(ScopedPredicateId::new(1)); sym2a.record_narrowing_constraint(&mut reachability_constraints, atom1); - let mut sym1b = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym1b = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym1b.record_binding( ScopedDefinitionId::from_u32(2), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -667,7 +641,7 @@ mod tests { assert_ne!(merged_constraint, atom2); // merging a constrained definition with unbound keeps both - let mut sym3a = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym3a = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym3a.record_binding( ScopedDefinitionId::from_u32(3), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -678,7 +652,7 @@ mod tests { let atom3 = reachability_constraints.add_atom(ScopedPredicateId::new(3)); sym3a.record_narrowing_constraint(&mut reachability_constraints, atom3); - let sym2b = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let sym2b = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym3a.merge(sym2b, &mut reachability_constraints); let sym3 = sym3a; @@ -708,7 +682,7 @@ mod tests { assert_eq!(bindings[2].1, atom3); // An unreachable branch should not dilute narrowing from the reachable branch. - let mut sym4a = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym4a = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym4a.record_binding( ScopedDefinitionId::from_u32(4), ScopedReachabilityConstraintId::ALWAYS_FALSE, @@ -717,7 +691,7 @@ mod tests { PreviousDefinitions::AreShadowed, ); - let mut sym4b = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym4b = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym4b.record_binding( ScopedDefinitionId::from_u32(4), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -735,14 +709,14 @@ mod tests { #[test] fn no_declaration() { - let sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); assert_declarations(&sym, &["undeclared"]); } #[test] fn record_declaration() { - let mut sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.record_declaration( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -753,7 +727,7 @@ mod tests { #[test] fn record_declaration_override() { - let mut sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.record_declaration( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -769,13 +743,13 @@ mod tests { #[test] fn record_declaration_merge() { let mut reachability_constraints = ReachabilityConstraintsBuilder::default(); - let mut sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.record_declaration( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, ); - let mut sym2 = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym2 = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym2.record_declaration( ScopedDefinitionId::from_u32(2), ScopedReachabilityConstraintId::ALWAYS_TRUE, @@ -789,13 +763,13 @@ mod tests { #[test] fn record_declaration_merge_partial_undeclared() { let mut reachability_constraints = ReachabilityConstraintsBuilder::default(); - let mut sym = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let mut sym = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.record_declaration( ScopedDefinitionId::from_u32(1), ScopedReachabilityConstraintId::ALWAYS_TRUE, ); - let sym2 = BuilderPlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + let sym2 = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); sym.merge(sym2, &mut reachability_constraints); From c002ce6bbb2f072cf4c10e8f8e86607ae62bc465 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 01:16:43 +0900 Subject: [PATCH 33/69] remove unnecessary code --- .../src/semantic_index/builder.rs | 66 ++----------------- .../src/semantic_index/use_def.rs | 11 +--- 2 files changed, 8 insertions(+), 69 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index ca7b71ac29a025..ef7354445492f1 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -68,8 +68,6 @@ struct Loop { break_states: Vec, /// Flow states at each `continue` in the current loop. continue_states: Vec, - /// Places that are bound within this loop body. - bound_places: FxHashSet, } impl Loop { @@ -265,11 +263,10 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { } /// Push a new loop, returning the outer loop, if any. - fn push_loop(&mut self, bound_places: FxHashSet) -> Option { + fn push_loop(&mut self) -> Option { self.current_scope_info_mut().current_loop.replace(Loop { break_states: Vec::default(), continue_states: Vec::default(), - bound_places, }) } @@ -1192,19 +1189,8 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { predicate: ScopedPredicateId, places: &PossiblyNarrowedPlaces, ) { - let allow_future_versions_for = self - .current_scope_info() - .current_loop - .as_ref() - .map_or_else(FxHashSet::default, |current_loop| { - places - .iter() - .copied() - .filter(|place| current_loop.bound_places.contains(place)) - .collect() - }); self.current_use_def_map_mut() - .record_narrowing_constraint_for_places(predicate, places, &allow_future_versions_for); + .record_narrowing_constraint_for_places(predicate, places); } /// Adds and records a narrowing constraint for only the places that could possibly be narrowed. @@ -1216,24 +1202,9 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { predicate: PredicateOrLiteral<'db>, ) -> ScopedPredicateId { let possibly_narrowed = self.compute_possibly_narrowed_places(&predicate); - let allow_future_versions_for = self - .current_scope_info() - .current_loop - .as_ref() - .map_or_else(FxHashSet::default, |current_loop| { - possibly_narrowed - .iter() - .copied() - .filter(|place| current_loop.bound_places.contains(place)) - .collect() - }); let use_def = self.current_use_def_map_mut(); let predicate_id = use_def.add_predicate(predicate); - use_def.record_narrowing_constraint_for_places( - predicate_id, - &possibly_narrowed, - &allow_future_versions_for, - ); + use_def.record_narrowing_constraint_for_places(predicate_id, &possibly_narrowed); predicate_id } @@ -1285,23 +1256,8 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { predicate_id: ScopedPredicateId, ) { let possibly_narrowed = self.compute_possibly_narrowed_places(&predicate); - let allow_future_versions_for = self - .current_scope_info() - .current_loop - .as_ref() - .map_or_else(FxHashSet::default, |current_loop| { - possibly_narrowed - .iter() - .copied() - .filter(|place| current_loop.bound_places.contains(place)) - .collect() - }); self.current_use_def_map_mut() - .record_negated_narrowing_constraint_for_places( - predicate_id, - &possibly_narrowed, - &allow_future_versions_for, - ); + .record_negated_narrowing_constraint_for_places(predicate_id, &possibly_narrowed); } /// Records that all remaining statements in the current block are unreachable. @@ -2468,12 +2424,7 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { let (predicate, predicate_id) = self.record_expression_narrowing_constraint(test); self.record_reachability_constraint_id(predicate_id); - let loop_bound_places = maybe_loop_header_info - .as_ref() - .map_or_else(FxHashSet::default, |(_, bound_place_ids)| { - bound_place_ids.clone() - }); - let outer_loop = self.push_loop(loop_bound_places); + let outer_loop = self.push_loop(); self.visit_body(body); let this_loop = self.pop_loop(outer_loop); @@ -2581,12 +2532,7 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { self.add_unpackable_assignment(&Unpackable::For(for_stmt), target, iter_expr); - let loop_bound_places = maybe_loop_header_info - .as_ref() - .map_or_else(FxHashSet::default, |(_, bound_place_ids)| { - bound_place_ids.clone() - }); - let outer_loop = self.push_loop(loop_bound_places); + let outer_loop = self.push_loop(); self.visit_body(body); let this_loop = self.pop_loop(outer_loop); diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index 66d1fb3848e347..80d826473e9d0e 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -242,7 +242,6 @@ use ruff_index::{IndexVec, newtype_index}; use rustc_hash::FxHashMap; -use rustc_hash::FxHashSet; use smallvec::SmallVec; use crate::node_key::NodeKey; @@ -1049,7 +1048,6 @@ impl<'db> UseDefMapBuilder<'db> { &mut self, predicate: ScopedPredicateId, places: &PossiblyNarrowedPlaces, - allow_future_versions_for: &FxHashSet, ) { if predicate == ScopedPredicateId::ALWAYS_TRUE || predicate == ScopedPredicateId::ALWAYS_FALSE @@ -1058,7 +1056,7 @@ impl<'db> UseDefMapBuilder<'db> { return; } - self.record_predicate_place_versions(predicate, places, allow_future_versions_for); + self.record_predicate_place_versions(predicate, places); let atom = self.reachability_constraints.add_atom(predicate); self.record_narrowing_constraint_node_for_places(atom, places); @@ -1074,7 +1072,6 @@ impl<'db> UseDefMapBuilder<'db> { &mut self, predicate: ScopedPredicateId, places: &PossiblyNarrowedPlaces, - allow_future_versions_for: &FxHashSet, ) { if predicate == ScopedPredicateId::ALWAYS_TRUE || predicate == ScopedPredicateId::ALWAYS_FALSE @@ -1082,7 +1079,7 @@ impl<'db> UseDefMapBuilder<'db> { return; } - self.record_predicate_place_versions(predicate, places, allow_future_versions_for); + self.record_predicate_place_versions(predicate, places); let atom = self.reachability_constraints.add_atom(predicate); let negated = self.reachability_constraints.add_not_constraint(atom); @@ -1093,7 +1090,6 @@ impl<'db> UseDefMapBuilder<'db> { &mut self, predicate: ScopedPredicateId, places: &PossiblyNarrowedPlaces, - allow_future_versions_for: &FxHashSet, ) { for place in places { let bindings = match place { @@ -1116,9 +1112,6 @@ impl<'db> UseDefMapBuilder<'db> { .predicate_place_versions .entry((predicate, *place)) .or_default(); - if allow_future_versions_for.contains(place) { - entry.allow_future_versions = true; - } for version in versions { if !entry.versions.contains(&version) { entry.versions.push(version); From 65bdd1c162f4bbb580c9ec9d9849decc50425d53 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 02:19:36 +0900 Subject: [PATCH 34/69] remove unnecessary code --- crates/ty_python_semantic/src/semantic_index/builder.rs | 7 +++---- .../src/semantic_index/reachability_constraints.rs | 8 +------- crates/ty_python_semantic/src/semantic_index/use_def.rs | 9 ++------- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index ef7354445492f1..2c081293182837 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -264,10 +264,9 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { /// Push a new loop, returning the outer loop, if any. fn push_loop(&mut self) -> Option { - self.current_scope_info_mut().current_loop.replace(Loop { - break_states: Vec::default(), - continue_states: Vec::default(), - }) + self.current_scope_info_mut() + .current_loop + .replace(Loop::default()) } /// Pop a loop, replacing with the previous saved outer loop, if any. diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 1a62fe428dd190..8b33111dfd30c7 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -952,13 +952,7 @@ impl ReachabilityConstraints { binding_place_version: Option, ) -> bool { binding_place_version.is_none_or(|binding_place_version| { - place_version_info.is_some_and(|info| { - info.versions.contains(&binding_place_version) - || info.allow_future_versions - && info - .max_version - .is_some_and(|max| binding_place_version > max) - }) + place_version_info.is_some_and(|info| info.versions.contains(&binding_place_version)) }) } diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index 80d826473e9d0e..36ecebfbe3de74 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -274,8 +274,6 @@ pub(crate) use place_state::{LiveBinding, PlaceVersion, ScopedDefinitionId}; #[derive(Clone, Debug, Default, PartialEq, Eq, salsa::Update, get_size2::GetSize)] pub(crate) struct PredicatePlaceVersionInfo { pub(crate) versions: SmallVec<[PlaceVersion; 2]>, - pub(crate) max_version: Option, - pub(crate) allow_future_versions: bool, } pub(crate) type PredicatePlaceVersions = @@ -1104,10 +1102,9 @@ impl<'db> UseDefMapBuilder<'db> { continue; }; - let versions: SmallVec<[PlaceVersion; 2]> = bindings + let versions = bindings .iter() - .map(|binding| self.definition_place_versions[binding.binding]) - .collect(); + .map(|binding| self.definition_place_versions[binding.binding]); let entry = self .predicate_place_versions .entry((predicate, *place)) @@ -1115,8 +1112,6 @@ impl<'db> UseDefMapBuilder<'db> { for version in versions { if !entry.versions.contains(&version) { entry.versions.push(version); - entry.max_version = - Some(entry.max_version.map_or(version, |max| max.max(version))); } } } From c1c0757e1404bd6960878835344d3be4eefce250 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 03:26:08 +0900 Subject: [PATCH 35/69] simplify `narrow_by_constraint_inner` logic --- .../reachability_constraints.rs | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 8b33111dfd30c7..c21eceb76c31d0 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -876,37 +876,26 @@ impl ReachabilityConstraints { is_positive: !predicate.is_positive, }; let place_version_info = predicate_place_versions.get(&(node.atom, place)); - let (pos_constraint, neg_constraint) = if place_version_info.is_some() { + let can_apply_narrowing = place_version_info.is_some() + && Self::predicate_applies_to_place_version( + place_version_info, + binding_place_version, + ); + let (pos_constraint, neg_constraint) = if can_apply_narrowing { ( infer_narrowing_constraint(db, predicate, place), infer_narrowing_constraint(db, neg_predicate, place), ) } else { // No recorded place-version metadata means this predicate cannot narrow - // this place, so skip the expensive narrowing-inference queries. + // this place, or the narrowing belongs to a different place version. + // In either case, skip the expensive narrowing-inference queries. (None, None) }; - // Only gate by place version if this predicate can narrow the current place. - // Predicates unrelated to `place` are still useful for reachability pruning. - let has_narrowing_constraints = - pos_constraint.is_some() || neg_constraint.is_some(); - if has_narrowing_constraints - && !Self::predicate_applies_to_place_version( - place_version_info, - binding_place_version, - ) - { - // This narrowing predicate belongs to an older/newer place version and - // must not influence narrowing for the current binding. - let true_ty = narrow!(node.if_true, accumulated.clone()); - let false_ty = narrow!(node.if_false, accumulated); - return UnionType::from_elements(db, [true_ty, false_ty]); - } - // If this predicate does not narrow the current place and we can statically // determine its truthiness, follow only the reachable branch. - if !has_narrowing_constraints { + if pos_constraint.is_none() && neg_constraint.is_none() { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { return narrow!(node.if_true, accumulated); From 3edbfdf05e647b23e3c9d4c688db7678d4043020 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 05:34:31 +0900 Subject: [PATCH 36/69] reduce redundancy checks in `narrow_by_constraint` --- .../src/semantic_index/reachability_constraints.rs | 11 ++++++++--- crates/ty_python_semantic/src/types.rs | 14 ++++++++++++++ crates/ty_python_semantic/src/types/builder.rs | 11 ++++++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index c21eceb76c31d0..f0c141761c3639 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -799,7 +799,7 @@ impl ReachabilityConstraints { ) -> Type<'db> { let mut memo = FxHashMap::default(); let mut truthiness_memo = FxHashMap::default(); - self.narrow_by_constraint_inner( + let redundant_union = self.narrow_by_constraint_inner( db, predicates, predicate_place_versions, @@ -810,7 +810,11 @@ impl ReachabilityConstraints { None, &mut memo, &mut truthiness_memo, - ) + ); + UnionBuilder::new(db) + .unpack_aliases(false) + .add(redundant_union) + .build() } /// Inner recursive helper that accumulates narrowing constraints along each TDD path. @@ -928,7 +932,8 @@ impl ReachabilityConstraints { let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = narrow!(node.if_false, false_accumulated); - UnionType::from_elements(db, [true_ty, false_ty]) + // We won't do a union type redundancy check here, as it only needs to be performed once for the final result. + UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]) } }; diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 043f23ca36ba34..c31f9e5a4c8191 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -12257,6 +12257,20 @@ impl<'db> UnionType<'db> { .build() } + pub(crate) fn from_elements_no_redundancy_check(db: &'db dyn Db, elements: I) -> Type<'db> + where + I: IntoIterator, + T: Into>, + { + elements + .into_iter() + .fold( + UnionBuilder::new(db).check_redundancy(false), + |builder, element| builder.add(element.into()), + ) + .build() + } + /// Create a union from a list of elements without unpacking type aliases. pub(crate) fn from_elements_leave_aliases(db: &'db dyn Db, elements: I) -> Type<'db> where diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 1ba0388b32e6eb..950b17d5515e0c 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -242,11 +242,13 @@ const MAX_NON_RECURSIVE_UNION_LITERALS: usize = 256; /// if reachability analysis etc. fails when analysing these enums. const MAX_NON_RECURSIVE_UNION_ENUM_LITERALS: usize = 8192; +#[allow(clippy::struct_excessive_bools)] pub(crate) struct UnionBuilder<'db> { elements: Vec>, db: &'db dyn Db, unpack_aliases: bool, order_elements: bool, + check_redundancy: bool, /// This is enabled when joining types in a `cycle_recovery` function. /// Since a cycle cannot be created within a `cycle_recovery` function, /// execution of `is_redundant_with` is skipped. @@ -261,6 +263,7 @@ impl<'db> UnionBuilder<'db> { elements: vec![], unpack_aliases: true, order_elements: false, + check_redundancy: true, cycle_recovery: false, recursively_defined: RecursivelyDefined::No, } @@ -276,9 +279,15 @@ impl<'db> UnionBuilder<'db> { self } + pub(crate) fn check_redundancy(mut self, val: bool) -> Self { + self.check_redundancy = val; + self + } + pub(crate) fn cycle_recovery(mut self, val: bool) -> Self { self.cycle_recovery = val; if self.cycle_recovery { + self.check_redundancy = false; self.unpack_aliases = false; } self @@ -622,7 +631,7 @@ impl<'db> UnionBuilder<'db> { // If an alias gets here, it means we aren't unpacking aliases, and we also // shouldn't try to simplify aliases out of the union, because that will require // unpacking them. - let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && !self.cycle_recovery; + let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && self.check_redundancy; let mut ty_negated: Option = None; let mut to_remove = SmallVec::<[usize; 2]>::new(); From 56dd81685f472eb7a7e41558cf6635e1e3a540ad Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 14:03:07 +0900 Subject: [PATCH 37/69] reduce redundancy checks in `narrow_by_constraint` (2) --- .../reachability_constraints.rs | 2 +- crates/ty_python_semantic/src/types/narrow.rs | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index f0c141761c3639..f779d38d3b6f9d 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -849,7 +849,7 @@ impl ReachabilityConstraints { match accumulated { Some(constraint) => NarrowingConstraint::intersection(base_ty) .merge_constraint_and(constraint, db) - .evaluate_constraint_type(db), + .evaluate_constraint_type(db, false), None => base_ty, } } diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index b7e1fbd8068a16..92fc66f109eccc 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -433,13 +433,20 @@ impl<'db> NarrowingConstraint<'db> { /// Evaluate the type this effectively constrains to /// /// Forgets whether each constraint originated from a `replacement` disjunct or not - pub(crate) fn evaluate_constraint_type(self, db: &'db dyn Db) -> Type<'db> { - UnionType::from_elements( - db, - self.replacement_disjuncts - .into_iter() - .chain(self.intersection_disjunct), - ) + pub(crate) fn evaluate_constraint_type( + self, + db: &'db dyn Db, + check_redundancy: bool, + ) -> Type<'db> { + let mut union = UnionBuilder::new(db).check_redundancy(check_redundancy); + for ty in self + .replacement_disjuncts + .into_iter() + .chain(self.intersection_disjunct) + { + union = union.add(ty); + } + union.build() } } From 3e0531476974350e901b0ac041ce8524548e3cb6 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 15:25:49 +0900 Subject: [PATCH 38/69] memorize all return values in `narrow_by_constraint_inner` --- .../semantic_index/reachability_constraints.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index f779d38d3b6f9d..2fd335dca5867a 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -902,10 +902,14 @@ impl ReachabilityConstraints { if pos_constraint.is_none() && neg_constraint.is_none() { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { - return narrow!(node.if_true, accumulated); + let narrowed = narrow!(node.if_true, accumulated); + memo.insert(key, narrowed); + return narrowed; } Truthiness::AlwaysFalse => { - return narrow!(node.if_false, accumulated); + let narrowed = narrow!(node.if_false, accumulated); + memo.insert(key, narrowed); + return narrowed; } Truthiness::Ambiguous => {} } @@ -914,13 +918,17 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); - return narrow!(node.if_false, false_accumulated); + let narrowed = narrow!(node.if_false, false_accumulated); + memo.insert(key, narrowed); + return narrowed; } // If the false branch is statically unreachable, skip it entirely. if node.if_false == ALWAYS_FALSE { let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); - return narrow!(node.if_true, true_accumulated); + let narrowed = narrow!(node.if_true, true_accumulated); + memo.insert(key, narrowed); + return narrowed; } // True branch: predicate holds → accumulate positive narrowing From 6b11b179a923a1686172d63697f303ff8878548f Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 16:55:18 +0900 Subject: [PATCH 39/69] narrowing constraint id canonicalization --- .../src/semantic_index/reachability_constraints.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 2fd335dca5867a..c4d1ba00f04173 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -838,7 +838,13 @@ impl ReachabilityConstraints { >, truthiness_memo: &mut FxHashMap, Truthiness>, ) -> Type<'db> { - let key = (id, accumulated.clone()); + // `ALWAYS_TRUE` and `AMBIGUOUS` are equivalent for narrowing purposes. + // Canonicalize to improve memo hits across terminal leaves. + let memo_id = match id { + ALWAYS_TRUE | AMBIGUOUS => ALWAYS_TRUE, + _ => id, + }; + let key = (memo_id, accumulated.clone()); if let Some(cached) = memo.get(&key).copied() { return cached; } From c171e966ff3925299f02f42a39f9a29f2351fa54 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 17:41:51 +0900 Subject: [PATCH 40/69] [ty] Propagate narrowing through always-true if-conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record if-statement conditions as narrowing constraints for all places in scope, not just those directly narrowed by the condition. This ensures that unreachable branches (e.g., the else-branch of `if 1 + 1 == 2:`) contribute `Never` rather than diluting narrowing from reachable branches. Uses the same ScopedPredicateId for both specific-places and all-places recording, so TDD atoms are shared via hash-consing. For places already narrowed by the condition, AND(atom, atom) = atom — no duplication. Co-Authored-By: Claude Opus 4.6 Co-Authored-By: Alex Gaynor --- .../src/semantic_index/builder.rs | 6 +++ .../src/semantic_index/use_def.rs | 45 +++++++++++++++++ .../src/semantic_index/use_def/place_state.rs | 49 ++----------------- 3 files changed, 56 insertions(+), 44 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index 2c081293182837..c8a8216d1642c1 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -2305,6 +2305,8 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { let mut no_branch_taken = self.flow_snapshot(); let (mut last_predicate, mut last_narrowing_id) = self.record_expression_narrowing_constraint(&node.test); + self.current_use_def_map_mut() + .record_narrowing_predicate_for_all_places(last_narrowing_id); let mut last_reachability_constraint = self.record_reachability_constraint_id(last_narrowing_id); @@ -2346,6 +2348,8 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { self.flow_restore(no_branch_taken.clone()); self.record_negated_narrowing_constraint(last_predicate, last_narrowing_id); + self.current_use_def_map_mut() + .record_negated_narrowing_predicate_for_all_places(last_narrowing_id); self.record_negated_reachability_constraint(last_reachability_constraint); if let Some(elif_test) = clause_test { @@ -2355,6 +2359,8 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { (last_predicate, last_narrowing_id) = self.record_expression_narrowing_constraint(elif_test); + self.current_use_def_map_mut() + .record_narrowing_predicate_for_all_places(last_narrowing_id); last_reachability_constraint = self.record_reachability_constraint_id(last_narrowing_id); diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index 36ecebfbe3de74..82bac2c477c643 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -1259,6 +1259,51 @@ impl<'db> UseDefMapBuilder<'db> { } } + /// Records a narrowing constraint (from a predicate ID) for all places in the current scope. + /// + /// This is used for if-statement conditions: even when a condition doesn't narrow any + /// specific variable, the condition's truthiness gates which branches are reachable. + /// By including the condition in all places' narrowing TDDs, we ensure that unreachable + /// branches (e.g., the else-branch of `if 1 + 1 == 2:`) contribute `Never` rather than + /// diluting narrowing from reachable branches. + /// + /// Uses the same `ScopedPredicateId` as `record_narrowing_constraint_for_places`, so + /// the TDD atom is shared (idempotent via hash-consing). For places that were already + /// narrowed by this predicate, `AND(atom, atom) = atom` — no duplication occurs. + pub(super) fn record_narrowing_predicate_for_all_places( + &mut self, + predicate: ScopedPredicateId, + ) { + if predicate == ScopedPredicateId::ALWAYS_TRUE + || predicate == ScopedPredicateId::ALWAYS_FALSE + { + return; + } + + let atom = self.reachability_constraints.add_atom(predicate); + self.record_narrowing_constraint_for_all_places(atom); + } + + /// Records a negated narrowing constraint (from a predicate ID) for all places in scope. + /// + /// Counterpart to [`Self::record_narrowing_predicate_for_all_places`] for the else/elif + /// branch. Uses TDD-level negation so that `atom(P) OR NOT(atom(P))` simplifies to + /// `ALWAYS_TRUE`, correctly cancelling narrowing when both branches flow through. + pub(super) fn record_negated_narrowing_predicate_for_all_places( + &mut self, + predicate: ScopedPredicateId, + ) { + if predicate == ScopedPredicateId::ALWAYS_TRUE + || predicate == ScopedPredicateId::ALWAYS_FALSE + { + return; + } + + let atom = self.reachability_constraints.add_atom(predicate); + let negated = self.reachability_constraints.add_not_constraint(atom); + self.record_narrowing_constraint_for_all_places(negated); + } + pub(super) fn record_reachability_constraint( &mut self, constraint: ScopedReachabilityConstraintId, diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index a3ff20a3c15d09..4b89a90fdaa995 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -364,29 +364,15 @@ impl Bindings { for zipped in a.merge_join_by(b, |a, b| a.binding.cmp(&b.binding)) { match zipped { EitherOrBoth::Both(a, b) => { + // If the same definition is visible through both paths, we OR the narrowing + // constraints: the type should be narrowed by whichever path was taken. + let narrowing_constraint = reachability_constraints + .add_or_constraint(a.narrowing_constraint, b.narrowing_constraint); + // For reachability constraints, we also merge using a ternary OR operation: let reachability_constraint = reachability_constraints .add_or_constraint(a.reachability_constraint, b.reachability_constraint); - let narrowing_constraint = if a.narrowing_constraint - == ScopedNarrowingConstraint::ALWAYS_TRUE - && b.narrowing_constraint == ScopedNarrowingConstraint::ALWAYS_TRUE - { - // short-circuit: if both sides are ALWAYS_TRUE, the result is ALWAYS_TRUE without needing to create a new TDD node. - ScopedNarrowingConstraint::ALWAYS_TRUE - } else { - // A branch contributes narrowing only when it is reachable. - // Without this gating, `OR(a_narrowing, b_narrowing)` allows an unreachable - // branch with `ALWAYS_TRUE` narrowing to cancel useful narrowing from the - // reachable branch. - let a_narrowing_gated = reachability_constraints - .add_and_constraint(a.narrowing_constraint, a.reachability_constraint); - let b_narrowing_gated = reachability_constraints - .add_and_constraint(b.narrowing_constraint, b.reachability_constraint); - reachability_constraints - .add_or_constraint(a_narrowing_gated, b_narrowing_gated) - }; - self.live_bindings.push(LiveBinding { binding: a.binding, narrowing_constraint, @@ -680,31 +666,6 @@ mod tests { assert_eq!(bindings[1].1, atom0); assert_eq!(bindings[2].0, 3); assert_eq!(bindings[2].1, atom3); - - // An unreachable branch should not dilute narrowing from the reachable branch. - let mut sym4a = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); - sym4a.record_binding( - ScopedDefinitionId::from_u32(4), - ScopedReachabilityConstraintId::ALWAYS_FALSE, - false, - true, - PreviousDefinitions::AreShadowed, - ); - - let mut sym4b = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); - sym4b.record_binding( - ScopedDefinitionId::from_u32(4), - ScopedReachabilityConstraintId::ALWAYS_TRUE, - false, - true, - PreviousDefinitions::AreShadowed, - ); - let atom4 = reachability_constraints.add_atom(ScopedPredicateId::new(4)); - sym4b.record_narrowing_constraint(&mut reachability_constraints, atom4); - - sym4a.merge(sym4b, &mut reachability_constraints); - let merged_constraint = sym4a.bindings().iter().next().unwrap().narrowing_constraint; - assert_eq!(merged_constraint, atom4); } #[test] From fdd4d9fc26aef7e421693eda7bfef44116420d53 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 18:03:41 +0900 Subject: [PATCH 41/69] Revert "[ty] Propagate narrowing through always-true if-conditions" This reverts commit c171e966ff3925299f02f42a39f9a29f2351fa54. --- .../src/semantic_index/builder.rs | 6 --- .../src/semantic_index/use_def.rs | 45 ----------------- .../src/semantic_index/use_def/place_state.rs | 49 +++++++++++++++++-- 3 files changed, 44 insertions(+), 56 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/builder.rs b/crates/ty_python_semantic/src/semantic_index/builder.rs index c8a8216d1642c1..2c081293182837 100644 --- a/crates/ty_python_semantic/src/semantic_index/builder.rs +++ b/crates/ty_python_semantic/src/semantic_index/builder.rs @@ -2305,8 +2305,6 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { let mut no_branch_taken = self.flow_snapshot(); let (mut last_predicate, mut last_narrowing_id) = self.record_expression_narrowing_constraint(&node.test); - self.current_use_def_map_mut() - .record_narrowing_predicate_for_all_places(last_narrowing_id); let mut last_reachability_constraint = self.record_reachability_constraint_id(last_narrowing_id); @@ -2348,8 +2346,6 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { self.flow_restore(no_branch_taken.clone()); self.record_negated_narrowing_constraint(last_predicate, last_narrowing_id); - self.current_use_def_map_mut() - .record_negated_narrowing_predicate_for_all_places(last_narrowing_id); self.record_negated_reachability_constraint(last_reachability_constraint); if let Some(elif_test) = clause_test { @@ -2359,8 +2355,6 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> { (last_predicate, last_narrowing_id) = self.record_expression_narrowing_constraint(elif_test); - self.current_use_def_map_mut() - .record_narrowing_predicate_for_all_places(last_narrowing_id); last_reachability_constraint = self.record_reachability_constraint_id(last_narrowing_id); diff --git a/crates/ty_python_semantic/src/semantic_index/use_def.rs b/crates/ty_python_semantic/src/semantic_index/use_def.rs index 82bac2c477c643..36ecebfbe3de74 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def.rs @@ -1259,51 +1259,6 @@ impl<'db> UseDefMapBuilder<'db> { } } - /// Records a narrowing constraint (from a predicate ID) for all places in the current scope. - /// - /// This is used for if-statement conditions: even when a condition doesn't narrow any - /// specific variable, the condition's truthiness gates which branches are reachable. - /// By including the condition in all places' narrowing TDDs, we ensure that unreachable - /// branches (e.g., the else-branch of `if 1 + 1 == 2:`) contribute `Never` rather than - /// diluting narrowing from reachable branches. - /// - /// Uses the same `ScopedPredicateId` as `record_narrowing_constraint_for_places`, so - /// the TDD atom is shared (idempotent via hash-consing). For places that were already - /// narrowed by this predicate, `AND(atom, atom) = atom` — no duplication occurs. - pub(super) fn record_narrowing_predicate_for_all_places( - &mut self, - predicate: ScopedPredicateId, - ) { - if predicate == ScopedPredicateId::ALWAYS_TRUE - || predicate == ScopedPredicateId::ALWAYS_FALSE - { - return; - } - - let atom = self.reachability_constraints.add_atom(predicate); - self.record_narrowing_constraint_for_all_places(atom); - } - - /// Records a negated narrowing constraint (from a predicate ID) for all places in scope. - /// - /// Counterpart to [`Self::record_narrowing_predicate_for_all_places`] for the else/elif - /// branch. Uses TDD-level negation so that `atom(P) OR NOT(atom(P))` simplifies to - /// `ALWAYS_TRUE`, correctly cancelling narrowing when both branches flow through. - pub(super) fn record_negated_narrowing_predicate_for_all_places( - &mut self, - predicate: ScopedPredicateId, - ) { - if predicate == ScopedPredicateId::ALWAYS_TRUE - || predicate == ScopedPredicateId::ALWAYS_FALSE - { - return; - } - - let atom = self.reachability_constraints.add_atom(predicate); - let negated = self.reachability_constraints.add_not_constraint(atom); - self.record_narrowing_constraint_for_all_places(negated); - } - pub(super) fn record_reachability_constraint( &mut self, constraint: ScopedReachabilityConstraintId, diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index 4b89a90fdaa995..a3ff20a3c15d09 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -364,15 +364,29 @@ impl Bindings { for zipped in a.merge_join_by(b, |a, b| a.binding.cmp(&b.binding)) { match zipped { EitherOrBoth::Both(a, b) => { - // If the same definition is visible through both paths, we OR the narrowing - // constraints: the type should be narrowed by whichever path was taken. - let narrowing_constraint = reachability_constraints - .add_or_constraint(a.narrowing_constraint, b.narrowing_constraint); - // For reachability constraints, we also merge using a ternary OR operation: let reachability_constraint = reachability_constraints .add_or_constraint(a.reachability_constraint, b.reachability_constraint); + let narrowing_constraint = if a.narrowing_constraint + == ScopedNarrowingConstraint::ALWAYS_TRUE + && b.narrowing_constraint == ScopedNarrowingConstraint::ALWAYS_TRUE + { + // short-circuit: if both sides are ALWAYS_TRUE, the result is ALWAYS_TRUE without needing to create a new TDD node. + ScopedNarrowingConstraint::ALWAYS_TRUE + } else { + // A branch contributes narrowing only when it is reachable. + // Without this gating, `OR(a_narrowing, b_narrowing)` allows an unreachable + // branch with `ALWAYS_TRUE` narrowing to cancel useful narrowing from the + // reachable branch. + let a_narrowing_gated = reachability_constraints + .add_and_constraint(a.narrowing_constraint, a.reachability_constraint); + let b_narrowing_gated = reachability_constraints + .add_and_constraint(b.narrowing_constraint, b.reachability_constraint); + reachability_constraints + .add_or_constraint(a_narrowing_gated, b_narrowing_gated) + }; + self.live_bindings.push(LiveBinding { binding: a.binding, narrowing_constraint, @@ -666,6 +680,31 @@ mod tests { assert_eq!(bindings[1].1, atom0); assert_eq!(bindings[2].0, 3); assert_eq!(bindings[2].1, atom3); + + // An unreachable branch should not dilute narrowing from the reachable branch. + let mut sym4a = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + sym4a.record_binding( + ScopedDefinitionId::from_u32(4), + ScopedReachabilityConstraintId::ALWAYS_FALSE, + false, + true, + PreviousDefinitions::AreShadowed, + ); + + let mut sym4b = PlaceState::undefined(ScopedReachabilityConstraintId::ALWAYS_TRUE); + sym4b.record_binding( + ScopedDefinitionId::from_u32(4), + ScopedReachabilityConstraintId::ALWAYS_TRUE, + false, + true, + PreviousDefinitions::AreShadowed, + ); + let atom4 = reachability_constraints.add_atom(ScopedPredicateId::new(4)); + sym4b.record_narrowing_constraint(&mut reachability_constraints, atom4); + + sym4a.merge(sym4b, &mut reachability_constraints); + let merged_constraint = sym4a.bindings().iter().next().unwrap().narrowing_constraint; + assert_eq!(merged_constraint, atom4); } #[test] From 4b1398c6a83846d2418705c1404d85d6a740d697 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 18:10:25 +0900 Subject: [PATCH 42/69] Update reachability_constraints.rs --- .../src/semantic_index/reachability_constraints.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index c4d1ba00f04173..caa5b6181af53b 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -202,6 +202,7 @@ use crate::Db; use crate::dunder_all::dunder_all_names; use crate::place::{RequiresExplicitReExport, imported_symbol}; use crate::rank::RankBitBox; +use crate::semantic_index::narrowing_constraints::ScopedNarrowingConstraint; use crate::semantic_index::place::ScopedPlaceId; use crate::semantic_index::place_table; use crate::semantic_index::predicate::{ @@ -792,7 +793,7 @@ impl ReachabilityConstraints { db: &'db dyn Db, predicates: &Predicates<'db>, predicate_place_versions: &PredicatePlaceVersions, - id: ScopedReachabilityConstraintId, + id: ScopedNarrowingConstraint, base_ty: Type<'db>, place: ScopedPlaceId, binding_place_version: Option, @@ -824,16 +825,13 @@ impl ReachabilityConstraints { db: &'db dyn Db, predicates: &Predicates<'db>, predicate_place_versions: &PredicatePlaceVersions, - id: ScopedReachabilityConstraintId, + id: ScopedNarrowingConstraint, base_ty: Type<'db>, place: ScopedPlaceId, binding_place_version: Option, accumulated: Option>, memo: &mut FxHashMap< - ( - ScopedReachabilityConstraintId, - Option>, - ), + (ScopedNarrowingConstraint, Option>), Type<'db>, >, truthiness_memo: &mut FxHashMap, Truthiness>, From 5d984f4c2c0d646072c0b63c505e1f07e6d2b06f Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 20:34:25 +0900 Subject: [PATCH 43/69] disable non-effective cache --- .../reachability_constraints.rs | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index caa5b6181af53b..8df846d91a9b58 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -830,10 +830,7 @@ impl ReachabilityConstraints { place: ScopedPlaceId, binding_place_version: Option, accumulated: Option>, - memo: &mut FxHashMap< - (ScopedNarrowingConstraint, Option>), - Type<'db>, - >, + memo: &mut FxHashMap>, truthiness_memo: &mut FxHashMap, Truthiness>, ) -> Type<'db> { // `ALWAYS_TRUE` and `AMBIGUOUS` are equivalent for narrowing purposes. @@ -842,12 +839,15 @@ impl ReachabilityConstraints { ALWAYS_TRUE | AMBIGUOUS => ALWAYS_TRUE, _ => id, }; - let key = (memo_id, accumulated.clone()); - if let Some(cached) = memo.get(&key).copied() { + // The cache is created and referenced only when `accumulated` is `None`. + // In the case of `Some(...)`, the hit rate is not good, so it is not very effective. + if accumulated.is_none() + && let Some(cached) = memo.get(&memo_id).copied() + { return cached; } - let narrowed = match id { + match id { ALWAYS_TRUE | AMBIGUOUS => { // Apply all accumulated narrowing constraints to the base type match accumulated { @@ -877,6 +877,16 @@ impl ReachabilityConstraints { ) }; } + macro_rules! narrow_cached { + ($next_id:expr, $next_accumulated:expr) => {{ + let no_accumulated = $next_accumulated.is_none(); + let narrowed = narrow!($next_id, $next_accumulated); + if no_accumulated { + memo.insert(memo_id, narrowed); + } + narrowed + }}; + } // Check if this predicate narrows the variable we're interested in. let neg_predicate = Predicate { @@ -906,14 +916,10 @@ impl ReachabilityConstraints { if pos_constraint.is_none() && neg_constraint.is_none() { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { - let narrowed = narrow!(node.if_true, accumulated); - memo.insert(key, narrowed); - return narrowed; + return narrow_cached!(node.if_true, accumulated); } Truthiness::AlwaysFalse => { - let narrowed = narrow!(node.if_false, accumulated); - memo.insert(key, narrowed); - return narrowed; + return narrow_cached!(node.if_false, accumulated); } Truthiness::Ambiguous => {} } @@ -922,35 +928,33 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); - let narrowed = narrow!(node.if_false, false_accumulated); - memo.insert(key, narrowed); - return narrowed; + return narrow_cached!(node.if_false, false_accumulated); } // If the false branch is statically unreachable, skip it entirely. if node.if_false == ALWAYS_FALSE { let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); - let narrowed = narrow!(node.if_true, true_accumulated); - memo.insert(key, narrowed); - return narrowed; + return narrow_cached!(node.if_true, true_accumulated); } // True branch: predicate holds → accumulate positive narrowing let true_accumulated = accumulate_constraint(db, accumulated.clone(), pos_constraint); + let no_true_accumulated = true_accumulated.is_none(); let true_ty = narrow!(node.if_true, true_accumulated); // False branch: predicate doesn't hold → accumulate negative narrowing let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); + let no_accumulated = no_true_accumulated && false_accumulated.is_none(); let false_ty = narrow!(node.if_false, false_accumulated); - // We won't do a union type redundancy check here, as it only needs to be performed once for the final result. - UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]) + let union = UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]); + if no_accumulated { + memo.insert(memo_id, union); + } + union } - }; - - memo.insert(key, narrowed); - narrowed + } } fn predicate_applies_to_place_version( From f75575565565c2b2f7013f3266dcbc35d57beb7d Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 21:31:29 +0900 Subject: [PATCH 44/69] Revert "disable non-effective cache" This reverts commit 5d984f4c2c0d646072c0b63c505e1f07e6d2b06f. --- .../reachability_constraints.rs | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 8df846d91a9b58..caa5b6181af53b 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -830,7 +830,10 @@ impl ReachabilityConstraints { place: ScopedPlaceId, binding_place_version: Option, accumulated: Option>, - memo: &mut FxHashMap>, + memo: &mut FxHashMap< + (ScopedNarrowingConstraint, Option>), + Type<'db>, + >, truthiness_memo: &mut FxHashMap, Truthiness>, ) -> Type<'db> { // `ALWAYS_TRUE` and `AMBIGUOUS` are equivalent for narrowing purposes. @@ -839,15 +842,12 @@ impl ReachabilityConstraints { ALWAYS_TRUE | AMBIGUOUS => ALWAYS_TRUE, _ => id, }; - // The cache is created and referenced only when `accumulated` is `None`. - // In the case of `Some(...)`, the hit rate is not good, so it is not very effective. - if accumulated.is_none() - && let Some(cached) = memo.get(&memo_id).copied() - { + let key = (memo_id, accumulated.clone()); + if let Some(cached) = memo.get(&key).copied() { return cached; } - match id { + let narrowed = match id { ALWAYS_TRUE | AMBIGUOUS => { // Apply all accumulated narrowing constraints to the base type match accumulated { @@ -877,16 +877,6 @@ impl ReachabilityConstraints { ) }; } - macro_rules! narrow_cached { - ($next_id:expr, $next_accumulated:expr) => {{ - let no_accumulated = $next_accumulated.is_none(); - let narrowed = narrow!($next_id, $next_accumulated); - if no_accumulated { - memo.insert(memo_id, narrowed); - } - narrowed - }}; - } // Check if this predicate narrows the variable we're interested in. let neg_predicate = Predicate { @@ -916,10 +906,14 @@ impl ReachabilityConstraints { if pos_constraint.is_none() && neg_constraint.is_none() { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { - return narrow_cached!(node.if_true, accumulated); + let narrowed = narrow!(node.if_true, accumulated); + memo.insert(key, narrowed); + return narrowed; } Truthiness::AlwaysFalse => { - return narrow_cached!(node.if_false, accumulated); + let narrowed = narrow!(node.if_false, accumulated); + memo.insert(key, narrowed); + return narrowed; } Truthiness::Ambiguous => {} } @@ -928,33 +922,35 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); - return narrow_cached!(node.if_false, false_accumulated); + let narrowed = narrow!(node.if_false, false_accumulated); + memo.insert(key, narrowed); + return narrowed; } // If the false branch is statically unreachable, skip it entirely. if node.if_false == ALWAYS_FALSE { let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); - return narrow_cached!(node.if_true, true_accumulated); + let narrowed = narrow!(node.if_true, true_accumulated); + memo.insert(key, narrowed); + return narrowed; } // True branch: predicate holds → accumulate positive narrowing let true_accumulated = accumulate_constraint(db, accumulated.clone(), pos_constraint); - let no_true_accumulated = true_accumulated.is_none(); let true_ty = narrow!(node.if_true, true_accumulated); // False branch: predicate doesn't hold → accumulate negative narrowing let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); - let no_accumulated = no_true_accumulated && false_accumulated.is_none(); let false_ty = narrow!(node.if_false, false_accumulated); + // We won't do a union type redundancy check here, as it only needs to be performed once for the final result. - let union = UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]); - if no_accumulated { - memo.insert(memo_id, union); - } - union + UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]) } - } + }; + + memo.insert(key, narrowed); + narrowed } fn predicate_applies_to_place_version( From 7e822883b5f2d35d0fa5162c55a0f40dec6d8607 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 21:51:47 +0900 Subject: [PATCH 45/69] disable non-effective cache (take 2) --- .../reachability_constraints.rs | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index caa5b6181af53b..b2312ab0be30e0 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -830,7 +830,7 @@ impl ReachabilityConstraints { place: ScopedPlaceId, binding_place_version: Option, accumulated: Option>, - memo: &mut FxHashMap< + terminal_memo: &mut FxHashMap< (ScopedNarrowingConstraint, Option>), Type<'db>, >, @@ -843,7 +843,10 @@ impl ReachabilityConstraints { _ => id, }; let key = (memo_id, accumulated.clone()); - if let Some(cached) = memo.get(&key).copied() { + // Only terminal IDs are recorded in the cache because other IDs have a low cache hit rate. + if memo_id.is_terminal() + && let Some(cached) = terminal_memo.get(&key).copied() + { return cached; } @@ -872,11 +875,20 @@ impl ReachabilityConstraints { place, binding_place_version, $next_accumulated, - memo, + terminal_memo, truthiness_memo, ) }; } + macro_rules! narrow_cached { + ($next_id:expr, $next_accumulated:expr) => {{ + let narrowed = narrow!($next_id, $next_accumulated); + if key.0.is_terminal() { + terminal_memo.insert(key, narrowed); + } + narrowed + }}; + } // Check if this predicate narrows the variable we're interested in. let neg_predicate = Predicate { @@ -906,14 +918,10 @@ impl ReachabilityConstraints { if pos_constraint.is_none() && neg_constraint.is_none() { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { - let narrowed = narrow!(node.if_true, accumulated); - memo.insert(key, narrowed); - return narrowed; + return narrow_cached!(node.if_true, accumulated); } Truthiness::AlwaysFalse => { - let narrowed = narrow!(node.if_false, accumulated); - memo.insert(key, narrowed); - return narrowed; + return narrow_cached!(node.if_false, accumulated); } Truthiness::Ambiguous => {} } @@ -922,17 +930,13 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); - let narrowed = narrow!(node.if_false, false_accumulated); - memo.insert(key, narrowed); - return narrowed; + return narrow_cached!(node.if_false, false_accumulated); } // If the false branch is statically unreachable, skip it entirely. if node.if_false == ALWAYS_FALSE { let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); - let narrowed = narrow!(node.if_true, true_accumulated); - memo.insert(key, narrowed); - return narrowed; + return narrow_cached!(node.if_true, true_accumulated); } // True branch: predicate holds → accumulate positive narrowing @@ -949,7 +953,9 @@ impl ReachabilityConstraints { } }; - memo.insert(key, narrowed); + if key.0.is_terminal() { + terminal_memo.insert(key, narrowed); + } narrowed } From 56c5011eaddca4e2c3277850e6f2eb4ee7528280 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 22:12:52 +0900 Subject: [PATCH 46/69] Revert "disable non-effective cache (take 2)" This reverts commit 7e822883b5f2d35d0fa5162c55a0f40dec6d8607. --- .../reachability_constraints.rs | 38 ++++++++----------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index b2312ab0be30e0..caa5b6181af53b 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -830,7 +830,7 @@ impl ReachabilityConstraints { place: ScopedPlaceId, binding_place_version: Option, accumulated: Option>, - terminal_memo: &mut FxHashMap< + memo: &mut FxHashMap< (ScopedNarrowingConstraint, Option>), Type<'db>, >, @@ -843,10 +843,7 @@ impl ReachabilityConstraints { _ => id, }; let key = (memo_id, accumulated.clone()); - // Only terminal IDs are recorded in the cache because other IDs have a low cache hit rate. - if memo_id.is_terminal() - && let Some(cached) = terminal_memo.get(&key).copied() - { + if let Some(cached) = memo.get(&key).copied() { return cached; } @@ -875,20 +872,11 @@ impl ReachabilityConstraints { place, binding_place_version, $next_accumulated, - terminal_memo, + memo, truthiness_memo, ) }; } - macro_rules! narrow_cached { - ($next_id:expr, $next_accumulated:expr) => {{ - let narrowed = narrow!($next_id, $next_accumulated); - if key.0.is_terminal() { - terminal_memo.insert(key, narrowed); - } - narrowed - }}; - } // Check if this predicate narrows the variable we're interested in. let neg_predicate = Predicate { @@ -918,10 +906,14 @@ impl ReachabilityConstraints { if pos_constraint.is_none() && neg_constraint.is_none() { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { - return narrow_cached!(node.if_true, accumulated); + let narrowed = narrow!(node.if_true, accumulated); + memo.insert(key, narrowed); + return narrowed; } Truthiness::AlwaysFalse => { - return narrow_cached!(node.if_false, accumulated); + let narrowed = narrow!(node.if_false, accumulated); + memo.insert(key, narrowed); + return narrowed; } Truthiness::Ambiguous => {} } @@ -930,13 +922,17 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); - return narrow_cached!(node.if_false, false_accumulated); + let narrowed = narrow!(node.if_false, false_accumulated); + memo.insert(key, narrowed); + return narrowed; } // If the false branch is statically unreachable, skip it entirely. if node.if_false == ALWAYS_FALSE { let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); - return narrow_cached!(node.if_true, true_accumulated); + let narrowed = narrow!(node.if_true, true_accumulated); + memo.insert(key, narrowed); + return narrowed; } // True branch: predicate holds → accumulate positive narrowing @@ -953,9 +949,7 @@ impl ReachabilityConstraints { } }; - if key.0.is_terminal() { - terminal_memo.insert(key, narrowed); - } + memo.insert(key, narrowed); narrowed } From 738cf8d2c32a67a181877ddce37ab9c031c37eb2 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Wed, 18 Feb 2026 22:32:35 +0900 Subject: [PATCH 47/69] cache two types intersection as a tracked function --- crates/ty_python_semantic/src/types.rs | 14 ++++++++++++++ crates/ty_python_semantic/src/types/narrow.rs | 10 ++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index c31f9e5a4c8191..10c4bd6a7ca393 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -12896,6 +12896,7 @@ pub(super) fn walk_intersection_type<'db, V: visitor::TypeVisitor<'db> + ?Sized> } } +#[salsa::tracked] impl<'db> IntersectionType<'db> { pub(crate) fn from_elements(db: &'db dyn Db, elements: I) -> Type<'db> where @@ -12907,6 +12908,19 @@ impl<'db> IntersectionType<'db> { .build() } + #[salsa::tracked( + cycle_initial=|_, id, _, _| Type::divergent(id), + cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _, _| { + result.cycle_normalized(db, *previous, cycle) + }, + heap_size=ruff_memory_usage::heap_size + )] + fn from_two_elements(db: &'db dyn Db, a: Type<'db>, b: Type<'db>) -> Type<'db> { + IntersectionBuilder::new(db) + .positive_elements([a, b]) + .build() + } + /// Return a new `IntersectionType` instance with the positive and negative types sorted /// according to a canonical ordering, and other normalizations applied to each element as applicable. /// diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 92fc66f109eccc..df523ffaf62b1f 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -404,9 +404,10 @@ impl<'db> NarrowingConstraint<'db> { }; let new_intersection_disjunct = self.intersection_disjunct.map(|intersection_disjunct| { - IntersectionType::from_elements( + IntersectionType::from_two_elements( db, - [intersection_disjunct, other_intersection_disjunct], + intersection_disjunct, + other_intersection_disjunct, ) }); @@ -414,9 +415,10 @@ impl<'db> NarrowingConstraint<'db> { self.replacement_disjuncts .iter() .map(|replacement_disjunct| { - IntersectionType::from_elements( + IntersectionType::from_two_elements( db, - [*replacement_disjunct, other_intersection_disjunct], + *replacement_disjunct, + other_intersection_disjunct, ) }); From a1e303ea0dd231d6f6e2773708e78b7e68b769f9 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Thu, 19 Feb 2026 02:38:33 +0900 Subject: [PATCH 48/69] `NarrowingConstraint` has `Cunjunction`s --- .../resources/mdtest/narrow/truthiness.md | 4 +- .../reachability_constraints.rs | 14 +- crates/ty_python_semantic/src/types.rs | 14 -- .../ty_python_semantic/src/types/builder.rs | 92 +++++++++- crates/ty_python_semantic/src/types/narrow.rs | 169 +++++++++++------- 5 files changed, 198 insertions(+), 95 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/truthiness.md b/crates/ty_python_semantic/resources/mdtest/narrow/truthiness.md index ff0c06e55ff008..5d52cb47daae46 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/truthiness.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/truthiness.md @@ -31,14 +31,14 @@ else: reveal_type(x) # revealed: Never if x or not x: - reveal_type(x) # revealed: Literal[0, -1, "", "foo", b"", b"bar"] | bool | None | tuple[()] + reveal_type(x) # revealed: Literal[-1, 0, "foo", "", b"bar", b""] | bool | None | tuple[()] else: reveal_type(x) # revealed: Never if not (x or not x): reveal_type(x) # revealed: Never else: - reveal_type(x) # revealed: Literal[0, -1, "", "foo", b"", b"bar"] | bool | None | tuple[()] + reveal_type(x) # revealed: Literal[-1, 0, "foo", "", b"bar", b""] | bool | None | tuple[()] if (isinstance(x, int) or isinstance(x, str)) and x: reveal_type(x) # revealed: Literal[-1, True, "foo"] diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index caa5b6181af53b..c5a36197e1733e 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -741,12 +741,11 @@ impl ReachabilityConstraintsBuilder { /// AND a new optional narrowing constraint with an accumulated one. fn accumulate_constraint<'db>( - db: &'db dyn Db, accumulated: Option>, new: Option>, ) -> Option> { match (accumulated, new) { - (Some(acc), Some(new_c)) => Some(new_c.merge_constraint_and(acc, db)), + (Some(acc), Some(new_c)) => Some(new_c.merge_constraint_and(acc)), (None, Some(new_c)) => Some(new_c), (Some(acc), None) => Some(acc), (None, None) => None, @@ -852,7 +851,7 @@ impl ReachabilityConstraints { // Apply all accumulated narrowing constraints to the base type match accumulated { Some(constraint) => NarrowingConstraint::intersection(base_ty) - .merge_constraint_and(constraint, db) + .merge_constraint_and(constraint) .evaluate_constraint_type(db, false), None => base_ty, } @@ -921,7 +920,7 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { - let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); + let false_accumulated = accumulate_constraint(accumulated, neg_constraint); let narrowed = narrow!(node.if_false, false_accumulated); memo.insert(key, narrowed); return narrowed; @@ -929,19 +928,18 @@ impl ReachabilityConstraints { // If the false branch is statically unreachable, skip it entirely. if node.if_false == ALWAYS_FALSE { - let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); + let true_accumulated = accumulate_constraint(accumulated, pos_constraint); let narrowed = narrow!(node.if_true, true_accumulated); memo.insert(key, narrowed); return narrowed; } // True branch: predicate holds → accumulate positive narrowing - let true_accumulated = - accumulate_constraint(db, accumulated.clone(), pos_constraint); + let true_accumulated = accumulate_constraint(accumulated.clone(), pos_constraint); let true_ty = narrow!(node.if_true, true_accumulated); // False branch: predicate doesn't hold → accumulate negative narrowing - let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); + let false_accumulated = accumulate_constraint(accumulated, neg_constraint); let false_ty = narrow!(node.if_false, false_accumulated); // We won't do a union type redundancy check here, as it only needs to be performed once for the final result. diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 10c4bd6a7ca393..c31f9e5a4c8191 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -12896,7 +12896,6 @@ pub(super) fn walk_intersection_type<'db, V: visitor::TypeVisitor<'db> + ?Sized> } } -#[salsa::tracked] impl<'db> IntersectionType<'db> { pub(crate) fn from_elements(db: &'db dyn Db, elements: I) -> Type<'db> where @@ -12908,19 +12907,6 @@ impl<'db> IntersectionType<'db> { .build() } - #[salsa::tracked( - cycle_initial=|_, id, _, _| Type::divergent(id), - cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _, _| { - result.cycle_normalized(db, *previous, cycle) - }, - heap_size=ruff_memory_usage::heap_size - )] - fn from_two_elements(db: &'db dyn Db, a: Type<'db>, b: Type<'db>) -> Type<'db> { - IntersectionBuilder::new(db) - .positive_elements([a, b]) - .build() - } - /// Return a new `IntersectionType` instance with the positive and negative types sorted /// according to a canonical ordering, and other normalizations applied to each element as applicable. /// diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 950b17d5515e0c..8e0fddc4e4b3d9 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -53,6 +53,81 @@ enum LiteralKind<'db> { Enum { enum_class: ClassLiteral<'db> }, } +/// Extract `(core, guard)` from truthiness-guarded intersections. +/// +/// e.g. +/// - `A & ~AlwaysTruthy` -> `Some((A, ~AlwaysTruthy))` +/// - `A & ~AlwaysFalsy` -> `Some((A, ~AlwaysFalsy))` +/// - `A` -> `None` +/// - `A & ~AlwaysTruthy & ~AlwaysFalsy` -> `None` (not a single-guard shape) +/// +/// This only recognizes the "single truthiness guard" forms used by truthiness narrowing. +fn split_truthiness_guarded_intersection<'db>( + db: &'db dyn Db, + ty: Type<'db>, +) -> Option<(Type<'db>, Type<'db>)> { + let Type::Intersection(intersection) = ty else { + return None; + }; + + let has_not_truthy = intersection.negative(db).contains(&Type::AlwaysTruthy); + let has_not_falsy = intersection.negative(db).contains(&Type::AlwaysFalsy); + let guard = match (has_not_truthy, has_not_falsy) { + (true, false) => Type::AlwaysTruthy.negate(db), + (false, true) => Type::AlwaysFalsy.negate(db), + _ => return None, + }; + + let mut core = IntersectionBuilder::new(db); + for positive in intersection.positive(db) { + core = core.add_positive(*positive); + } + for negative in intersection.negative(db) { + if (guard == Type::AlwaysTruthy.negate(db) && *negative == Type::AlwaysTruthy) + || (guard == Type::AlwaysFalsy.negate(db) && *negative == Type::AlwaysFalsy) + { + continue; + } + core = core.add_negative(*negative); + } + Some((core.build(), guard)) +} + +/// Try to merge a complementary guarded pair into an unguarded core. +/// +/// e.g. +/// - `(A & ~AlwaysTruthy, A & ~AlwaysFalsy)` -> `Some(A)` +/// - `(A & ~AlwaysTruthy, B & ~AlwaysFalsy)` -> `Some(A | B)` if reconstruction is exact +/// - `(A & ~AlwaysTruthy, C)` -> `None` +/// +/// Safety rule: +/// The candidate merge is accepted only if adding each original guard back reconstructs +/// exactly the original operands (`left` and `right`). +fn merge_truthiness_guarded_pair<'db>( + db: &'db dyn Db, + left: Type<'db>, + right: Type<'db>, +) -> Option> { + let (left_core, left_guard) = split_truthiness_guarded_intersection(db, left)?; + let (right_core, right_guard) = split_truthiness_guarded_intersection(db, right)?; + if left_guard == right_guard { + return None; + } + + if left_core.is_equivalent_to(db, right_core) { + return Some(left_core); + } + + let candidate = UnionType::from_elements(db, [left_core, right_core]); + let left_reconstructed = IntersectionType::from_elements(db, [candidate, left_guard]); + let right_reconstructed = IntersectionType::from_elements(db, [candidate, right_guard]); + if left_reconstructed == left && right_reconstructed == right { + Some(candidate) + } else { + None + } +} + impl<'db> Type<'db> { /// Return `true` if this type can be a supertype of some literals of `kind` and not others. fn splits_literals(self, db: &'db dyn Db, kind: LiteralKind) -> bool { @@ -622,10 +697,10 @@ impl<'db> UnionBuilder<'db> { } fn push_type(&mut self, ty: Type<'db>, seen_aliases: &mut Vec>) { - let bool_pair = if let Type::BooleanLiteral(b) = ty { - Some(Type::BooleanLiteral(!b)) - } else { - None + let mut ty = ty; + let bool_pair = |ty: Type<'db>| match ty { + Type::BooleanLiteral(b) => Some(Type::BooleanLiteral(!b)), + _ => None, }; // If an alias gets here, it means we aren't unpacking aliases, and we also @@ -658,7 +733,14 @@ impl<'db> UnionBuilder<'db> { return; } - if Some(element_type) == bool_pair { + // Fold `(T & ~AlwaysTruthy) | (T & ~AlwaysFalsy)` to `T`. + if let Some(merged_type) = merge_truthiness_guarded_pair(self.db, ty, element_type) { + to_remove.push(i); + ty = merged_type; + continue; + } + + if Some(element_type) == bool_pair(ty) { self.add_in_place_impl(KnownClass::Bool.to_instance(self.db), seen_aliases); return; } diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index df523ffaf62b1f..2f92e3b7610a65 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -332,6 +332,44 @@ impl ClassInfoConstraintFunction { } } +#[derive(Hash, PartialEq, Debug, Eq, Clone, salsa::Update, get_size2::GetSize)] +struct Conjunctions<'db> { + conjuncts: SmallVec<[Type<'db>; 2]>, +} + +impl<'db> Conjunctions<'db> { + fn singleton(ty: Type<'db>) -> Self { + let mut conjuncts = SmallVec::new(); + conjuncts.push(ty); + Self { conjuncts } + } + + fn and_with(mut self, other: Self) -> Self { + if self.conjuncts.iter().any(Type::is_never) || other.conjuncts.iter().any(Type::is_never) { + return Self::singleton(Type::Never); + } + + for conjunct in other.conjuncts { + if !self.conjuncts.contains(&conjunct) { + self.conjuncts.push(conjunct); + } + } + self + } + + fn evaluate_constraint_type(self, db: &'db dyn Db) -> Type<'db> { + if self.conjuncts.len() == 1 { + return self.conjuncts[0]; + } + + let mut intersection = IntersectionBuilder::new(db); + for conjunct in self.conjuncts { + intersection = intersection.add_positive(conjunct); + } + intersection.build() + } +} + /// Represents narrowing constraints in Disjunctive Normal Form (DNF). /// /// This is a disjunction (OR) of conjunctions (AND) of constraints. @@ -341,30 +379,30 @@ impl ClassInfoConstraintFunction { /// For example: /// - `f(x) and g(x)` where f returns `TypeIs[A]` and g returns `TypeGuard[B]` /// => and -/// ===> `NarrowingConstraint { intersection_disjunct: Some(A), replacement_disjuncts: [] }` -/// ===> `NarrowingConstraint { intersection_disjunct: None, replacement_disjuncts: [B] }` -/// => `NarrowingConstraint { intersection_disjunct: None, replacement_disjuncts: [B] }` +/// ===> `NarrowingConstraint { intersection_disjuncts: [A], replacement_disjuncts: [] }` +/// ===> `NarrowingConstraint { intersection_disjuncts: [], replacement_disjuncts: [B] }` +/// => `NarrowingConstraint { intersection_disjuncts: [], replacement_disjuncts: [B] }` /// => evaluates to `B` (`TypeGuard` clobbers any previous type information) /// /// - `f(x) or g(x)` where f returns `TypeIs[A]` and g returns `TypeGuard[B]` /// => or -/// ===> `NarrowingConstraint { intersection_disjunct: Some(A), replacement_disjuncts: [] }` -/// ===> `NarrowingConstraint { intersection_disjunct: None, replacement_disjuncts: [B] }` -/// => `NarrowingConstraint { intersection_disjunct: Some(A), replacement_disjuncts: [B] }` +/// ===> `NarrowingConstraint { intersection_disjuncts: [A], replacement_disjuncts: [] }` +/// ===> `NarrowingConstraint { intersection_disjuncts: [], replacement_disjuncts: [B] }` +/// => `NarrowingConstraint { intersection_disjuncts: [A], replacement_disjuncts: [B] }` /// => evaluates to `(P & A) | B`, where `P` is our previously-known type #[derive(Hash, PartialEq, Debug, Eq, Clone, salsa::Update, get_size2::GetSize)] pub(crate) struct NarrowingConstraint<'db> { /// Intersection constraint (from `isinstance()` narrowing comparisons, `TypeIs`, and - /// similar). We can use a single type here because we can eagerly union disjunctions - /// and eagerly intersect conjunctions. - intersection_disjunct: Option>, + /// similar). We keep these as a disjunction of conjunctions to avoid constructing + /// union/intersection types while merging constraints. + intersection_disjuncts: SmallVec<[Conjunctions<'db>; 1]>, /// "Replacement" constraints: instead of intersecting the previous type with a new type, /// the previous type is simply replaced wholesale with the new type. A common use case for /// these constraints is `typing.TypeGuard`. We can't eagerly union disjunctions because /// `TypeGuard` clobbers the previously-known type; within each replacement disjunct, however, /// we may eagerly intersect conjunctions with a later intersection narrowing. - replacement_disjuncts: SmallVec<[Type<'db>; 1]>, + replacement_disjuncts: SmallVec<[Conjunctions<'db>; 1]>, } impl<'db> NarrowingConstraint<'db> { @@ -372,7 +410,7 @@ impl<'db> NarrowingConstraint<'db> { /// intersected with this constraint pub(crate) fn intersection(constraint: Type<'db>) -> Self { Self { - intersection_disjunct: Some(constraint), + intersection_disjuncts: smallvec_inline![Conjunctions::singleton(constraint)], replacement_disjuncts: smallvec![], } } @@ -381,53 +419,62 @@ impl<'db> NarrowingConstraint<'db> { /// replaced wholesale with this constraint fn replacement(constraint: Type<'db>) -> Self { Self { - intersection_disjunct: None, - replacement_disjuncts: smallvec_inline![constraint], + intersection_disjuncts: smallvec![], + replacement_disjuncts: smallvec_inline![Conjunctions::singleton(constraint)], } } /// Merge two constraints, taking their intersection but respecting "replacement" semantics (with /// `other` winning) - pub(crate) fn merge_constraint_and(&self, other: Self, db: &'db dyn Db) -> Self { + pub(crate) fn merge_constraint_and(&self, other: Self) -> Self { // Distribute AND over OR: (A1 | A2 | ...) AND (B1 | B2 | ...) // becomes (A1 & B1) | (A1 & B2) | ... | (A2 & B1) | ... // // In our representation, the RHS `replacement_disjuncts` will all clobber the LHS disjuncts // when they are `and`ed, so they'll just stay as is. // - // The thing we actually need to deal with is the RHS `intersection_disjunct`. It gets - // intersected with the LHS `intersection_disjunct` to form the new `intersection_disjunct`, + // The thing we actually need to deal with is the RHS `intersection_disjuncts`. Each RHS + // disjunct gets intersected with each LHS disjunct, producing the cartesian product. + // This is still deferred as conjunction lists. + // + // We also intersect each LHS `replacement_disjunct` with every RHS intersection disjunct to form new additional // and intersected with each LHS `replacement_disjunct` to form new additional // `replacement_disjuncts`. - let Some(other_intersection_disjunct) = other.intersection_disjunct else { + if other.intersection_disjuncts.is_empty() { return other; - }; + } - let new_intersection_disjunct = self.intersection_disjunct.map(|intersection_disjunct| { - IntersectionType::from_two_elements( - db, - intersection_disjunct, - other_intersection_disjunct, - ) - }); + let mut new_intersection_disjuncts = SmallVec::new(); + for intersection_disjunct in &self.intersection_disjuncts { + for other_intersection_disjunct in &other.intersection_disjuncts { + let merged = intersection_disjunct + .clone() + .and_with(other_intersection_disjunct.clone()); + if !new_intersection_disjuncts.contains(&merged) { + new_intersection_disjuncts.push(merged); + } + } + } - let additional_replacement_disjuncts = - self.replacement_disjuncts - .iter() - .map(|replacement_disjunct| { - IntersectionType::from_two_elements( - db, - *replacement_disjunct, - other_intersection_disjunct, - ) - }); + let mut additional_replacement_disjuncts: SmallVec<[Conjunctions<'db>; 1]> = + SmallVec::new(); + for replacement_disjunct in &self.replacement_disjuncts { + for other_intersection_disjunct in &other.intersection_disjuncts { + let merged = replacement_disjunct + .clone() + .and_with(other_intersection_disjunct.clone()); + if !additional_replacement_disjuncts.contains(&merged) { + additional_replacement_disjuncts.push(merged); + } + } + } let mut new_replacement_disjuncts = other.replacement_disjuncts; new_replacement_disjuncts.extend(additional_replacement_disjuncts); NarrowingConstraint { - intersection_disjunct: new_intersection_disjunct, + intersection_disjuncts: new_intersection_disjuncts, replacement_disjuncts: new_replacement_disjuncts, } } @@ -441,12 +488,12 @@ impl<'db> NarrowingConstraint<'db> { check_redundancy: bool, ) -> Type<'db> { let mut union = UnionBuilder::new(db).check_redundancy(check_redundancy); - for ty in self + for conjunctions in self .replacement_disjuncts .into_iter() - .chain(self.intersection_disjunct) + .chain(self.intersection_disjuncts) { - union = union.add(ty); + union = union.add(conjunctions.evaluate_constraint_type(db)); } union.build() } @@ -472,14 +519,13 @@ type NarrowingConstraints<'db> = FxHashMap( into: &mut NarrowingConstraints<'db>, from: NarrowingConstraints<'db>, - db: &'db dyn Db, ) { for (key, from_constraint) in from { match into.entry(key) { Entry::Occupied(mut entry) => { let into_constraint = entry.get(); - entry.insert(into_constraint.merge_constraint_and(from_constraint, db)); + entry.insert(into_constraint.merge_constraint_and(from_constraint)); } Entry::Vacant(entry) => { entry.insert(from_constraint); @@ -498,7 +544,6 @@ fn merge_constraints_and<'db>( fn merge_constraints_or<'db>( into: &mut NarrowingConstraints<'db>, from: NarrowingConstraints<'db>, - db: &'db dyn Db, ) { // For places that appear in `into` but not in `from`, widen to object into.retain(|key, _| from.contains_key(key)); @@ -507,16 +552,10 @@ fn merge_constraints_or<'db>( match into.entry(key) { Entry::Occupied(mut entry) => { let into_constraint = entry.get_mut(); - // Union the intersection constraints - into_constraint.intersection_disjunct = match ( - into_constraint.intersection_disjunct, - from_constraint.intersection_disjunct, - ) { - (Some(a), Some(b)) => Some(UnionType::from_elements(db, [a, b])), - (Some(a), None) => Some(a), - (None, Some(b)) => Some(b), - (None, None) => None, - }; + // Union the intersection constraints by concatenating disjunct lists. + into_constraint + .intersection_disjuncts + .extend(from_constraint.intersection_disjuncts); // Concatenate replacement disjuncts into_constraint @@ -593,13 +632,12 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { } fn merge_constraints_and_sequence( - &self, sub_constraints: Vec>>, ) -> Option> { let mut aggregation: Option> = None; for sub_constraint in sub_constraints.into_iter().flatten() { if let Some(ref mut some_aggregation) = aggregation { - merge_constraints_and(some_aggregation, sub_constraint, self.db); + merge_constraints_and(some_aggregation, sub_constraint); } else { aggregation = Some(sub_constraint); } @@ -608,7 +646,6 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { } fn merge_constraints_or_sequence( - &self, sub_constraints: Vec>>, ) -> Option> { let (mut first, rest) = { @@ -619,7 +656,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { if let Some(ref mut first) = first { for rest_constraint in rest { if let Some(rest_constraint) = rest_constraint { - merge_constraints_or(first, rest_constraint, self.db); + merge_constraints_or(first, rest_constraint); } else { return None; } @@ -1235,7 +1272,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { constraints .entry(place) .and_modify(|existing| { - *existing = existing.merge_constraint_and(constraint.clone(), self.db); + *existing = existing.merge_constraint_and(constraint.clone()); }) .or_insert(constraint); } else if let Some((place, constraint)) = self.narrow_tuple_subscript( @@ -1248,7 +1285,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { constraints .entry(place) .and_modify(|existing| { - *existing = existing.merge_constraint_and(constraint.clone(), self.db); + *existing = existing.merge_constraint_and(constraint.clone()); }) .or_insert(constraint); } @@ -1414,7 +1451,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { constraints .entry(place) .and_modify(|existing| { - *existing = existing.merge_constraint_and(constraint.clone(), self.db); + *existing = existing.merge_constraint_and(constraint.clone()); }) .or_insert(constraint); } @@ -1440,7 +1477,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { constraints .entry(place) .and_modify(|existing| { - *existing = existing.merge_constraint_and(constraint.clone(), self.db); + *existing = existing.merge_constraint_and(constraint.clone()); }) .or_insert(constraint); @@ -1782,7 +1819,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { if let Some(sub_positive) = sub_positive { if let Some(ref mut aggregated) = positive { - merge_constraints_or(aggregated, sub_positive, self.db); + merge_constraints_or(aggregated, sub_positive); } else { positive = Some(sub_positive); } @@ -1790,7 +1827,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { if let Some(sub_negative) = sub_negative { if let Some(ref mut aggregated) = negative { - merge_constraints_and(aggregated, sub_negative, self.db); + merge_constraints_and(aggregated, sub_negative); } else { negative = Some(sub_negative); } @@ -1828,12 +1865,12 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { let (positive, negative) = match expr_bool_op.op { BoolOp::And => ( - self.merge_constraints_and_sequence(positive_sub_constraints), - self.merge_constraints_or_sequence(negative_sub_constraints), + Self::merge_constraints_and_sequence(positive_sub_constraints), + Self::merge_constraints_or_sequence(negative_sub_constraints), ), BoolOp::Or => ( - self.merge_constraints_or_sequence(positive_sub_constraints), - self.merge_constraints_and_sequence(negative_sub_constraints), + Self::merge_constraints_or_sequence(positive_sub_constraints), + Self::merge_constraints_and_sequence(negative_sub_constraints), ), }; From 2c8bade7bb5c70a085736a5bf5291ed4002dd8f4 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Thu, 19 Feb 2026 03:39:35 +0900 Subject: [PATCH 49/69] add `GatedNarrowingConstraint` --- .../reachability_constraints.rs | 114 +++++++++++------- crates/ty_python_semantic/src/types.rs | 14 --- .../ty_python_semantic/src/types/builder.rs | 5 - crates/ty_python_semantic/src/types/narrow.rs | 17 ++- 4 files changed, 83 insertions(+), 67 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index c5a36197e1733e..4b7626d5ff73e1 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -752,6 +752,41 @@ fn accumulate_constraint<'db>( } } +/// Reachability-gated narrowing constraints +#[derive(Clone)] +enum GatedNarrowingConstraint<'db> { + Unreachable, + Reachable(Option>), +} + +impl<'db> GatedNarrowingConstraint<'db> { + fn merge_constraint_or( + self, + other: GatedNarrowingConstraint<'db>, + ) -> GatedNarrowingConstraint<'db> { + match (self, other) { + (GatedNarrowingConstraint::Unreachable, other) + | (other, GatedNarrowingConstraint::Unreachable) => other, + ( + GatedNarrowingConstraint::Reachable(None), + GatedNarrowingConstraint::Reachable(None), + ) => GatedNarrowingConstraint::Reachable(None), + ( + GatedNarrowingConstraint::Reachable(left_constraint), + GatedNarrowingConstraint::Reachable(right_constraint), + ) => { + let left_constraint = left_constraint + .unwrap_or_else(|| NarrowingConstraint::intersection(Type::object())); + let right_constraint = right_constraint + .unwrap_or_else(|| NarrowingConstraint::intersection(Type::object())); + GatedNarrowingConstraint::Reachable(Some( + left_constraint.merge_constraint_or(right_constraint), + )) + } + } + } +} + impl ReachabilityConstraints { /// Look up an interior node by its constraint ID. fn get_interior_node(&self, id: ScopedReachabilityConstraintId) -> InteriorNode { @@ -799,22 +834,26 @@ impl ReachabilityConstraints { ) -> Type<'db> { let mut memo = FxHashMap::default(); let mut truthiness_memo = FxHashMap::default(); - let redundant_union = self.narrow_by_constraint_inner( + let constraint = self.narrow_by_constraint_inner( db, predicates, predicate_place_versions, id, - base_ty, place, binding_place_version, None, &mut memo, &mut truthiness_memo, ); - UnionBuilder::new(db) - .unpack_aliases(false) - .add(redundant_union) - .build() + match constraint { + GatedNarrowingConstraint::Unreachable => Type::Never, + GatedNarrowingConstraint::Reachable(Some(constraint)) => { + NarrowingConstraint::intersection(base_ty) + .merge_constraint_and(constraint) + .evaluate_constraint_type(db) + } + GatedNarrowingConstraint::Reachable(None) => base_ty, + } } /// Inner recursive helper that accumulates narrowing constraints along each TDD path. @@ -825,16 +864,15 @@ impl ReachabilityConstraints { predicates: &Predicates<'db>, predicate_place_versions: &PredicatePlaceVersions, id: ScopedNarrowingConstraint, - base_ty: Type<'db>, place: ScopedPlaceId, binding_place_version: Option, accumulated: Option>, memo: &mut FxHashMap< (ScopedNarrowingConstraint, Option>), - Type<'db>, + GatedNarrowingConstraint<'db>, >, truthiness_memo: &mut FxHashMap, Truthiness>, - ) -> Type<'db> { + ) -> GatedNarrowingConstraint<'db> { // `ALWAYS_TRUE` and `AMBIGUOUS` are equivalent for narrowing purposes. // Canonicalize to improve memo hits across terminal leaves. let memo_id = match id { @@ -842,32 +880,25 @@ impl ReachabilityConstraints { _ => id, }; let key = (memo_id, accumulated.clone()); - if let Some(cached) = memo.get(&key).copied() { - return cached; + if let Some(cached) = memo.get(&key) { + return cached.clone(); } - let narrowed = match id { - ALWAYS_TRUE | AMBIGUOUS => { - // Apply all accumulated narrowing constraints to the base type - match accumulated { - Some(constraint) => NarrowingConstraint::intersection(base_ty) - .merge_constraint_and(constraint) - .evaluate_constraint_type(db, false), - None => base_ty, - } - } - ALWAYS_FALSE => Type::Never, + let constraint = match id { + // Return accumulated narrowing constraints and defer actual type construction + // until `narrow_by_constraint`. + ALWAYS_TRUE | AMBIGUOUS => GatedNarrowingConstraint::Reachable(accumulated), + ALWAYS_FALSE => GatedNarrowingConstraint::Unreachable, _ => { let node = self.get_interior_node(id); let predicate = predicates[node.atom]; - macro_rules! narrow { + macro_rules! get_constraint { ($next_id:expr, $next_accumulated:expr) => { self.narrow_by_constraint_inner( db, predicates, predicate_place_versions, $next_id, - base_ty, place, binding_place_version, $next_accumulated, @@ -905,14 +936,14 @@ impl ReachabilityConstraints { if pos_constraint.is_none() && neg_constraint.is_none() { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { - let narrowed = narrow!(node.if_true, accumulated); - memo.insert(key, narrowed); - return narrowed; + let constraint = get_constraint!(node.if_true, accumulated); + memo.insert(key, constraint.clone()); + return constraint; } Truthiness::AlwaysFalse => { - let narrowed = narrow!(node.if_false, accumulated); - memo.insert(key, narrowed); - return narrowed; + let constraint = get_constraint!(node.if_false, accumulated); + memo.insert(key, constraint.clone()); + return constraint; } Truthiness::Ambiguous => {} } @@ -921,34 +952,33 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { let false_accumulated = accumulate_constraint(accumulated, neg_constraint); - let narrowed = narrow!(node.if_false, false_accumulated); - memo.insert(key, narrowed); - return narrowed; + let constraint = get_constraint!(node.if_false, false_accumulated); + memo.insert(key, constraint.clone()); + return constraint; } // If the false branch is statically unreachable, skip it entirely. if node.if_false == ALWAYS_FALSE { let true_accumulated = accumulate_constraint(accumulated, pos_constraint); - let narrowed = narrow!(node.if_true, true_accumulated); - memo.insert(key, narrowed); - return narrowed; + let constraint = get_constraint!(node.if_true, true_accumulated); + memo.insert(key, constraint.clone()); + return constraint; } // True branch: predicate holds → accumulate positive narrowing let true_accumulated = accumulate_constraint(accumulated.clone(), pos_constraint); - let true_ty = narrow!(node.if_true, true_accumulated); + let true_constraint = get_constraint!(node.if_true, true_accumulated); // False branch: predicate doesn't hold → accumulate negative narrowing let false_accumulated = accumulate_constraint(accumulated, neg_constraint); - let false_ty = narrow!(node.if_false, false_accumulated); + let false_constraint = get_constraint!(node.if_false, false_accumulated); - // We won't do a union type redundancy check here, as it only needs to be performed once for the final result. - UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]) + true_constraint.merge_constraint_or(false_constraint) } }; - memo.insert(key, narrowed); - narrowed + memo.insert(key, constraint.clone()); + constraint } fn predicate_applies_to_place_version( diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index c31f9e5a4c8191..043f23ca36ba34 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -12257,20 +12257,6 @@ impl<'db> UnionType<'db> { .build() } - pub(crate) fn from_elements_no_redundancy_check(db: &'db dyn Db, elements: I) -> Type<'db> - where - I: IntoIterator, - T: Into>, - { - elements - .into_iter() - .fold( - UnionBuilder::new(db).check_redundancy(false), - |builder, element| builder.add(element.into()), - ) - .build() - } - /// Create a union from a list of elements without unpacking type aliases. pub(crate) fn from_elements_leave_aliases(db: &'db dyn Db, elements: I) -> Type<'db> where diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 8e0fddc4e4b3d9..3b8726306b3502 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -354,11 +354,6 @@ impl<'db> UnionBuilder<'db> { self } - pub(crate) fn check_redundancy(mut self, val: bool) -> Self { - self.check_redundancy = val; - self - } - pub(crate) fn cycle_recovery(mut self, val: bool) -> Self { self.cycle_recovery = val; if self.cycle_recovery { diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 2f92e3b7610a65..c3485c2fb682ef 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -479,15 +479,20 @@ impl<'db> NarrowingConstraint<'db> { } } + /// Merge two constraints with OR semantics (union/disjunction). + pub(crate) fn merge_constraint_or(mut self, other: Self) -> Self { + self.intersection_disjuncts + .extend(other.intersection_disjuncts); + self.replacement_disjuncts + .extend(other.replacement_disjuncts); + self + } + /// Evaluate the type this effectively constrains to /// /// Forgets whether each constraint originated from a `replacement` disjunct or not - pub(crate) fn evaluate_constraint_type( - self, - db: &'db dyn Db, - check_redundancy: bool, - ) -> Type<'db> { - let mut union = UnionBuilder::new(db).check_redundancy(check_redundancy); + pub(crate) fn evaluate_constraint_type(self, db: &'db dyn Db) -> Type<'db> { + let mut union = UnionBuilder::new(db); for conjunctions in self .replacement_disjuncts .into_iter() From 5b6f7f21a786933e178b73901733058c1cf88e66 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Thu, 19 Feb 2026 12:50:24 +0900 Subject: [PATCH 50/69] intern `NarrowingConstraint` --- .../reachability_constraints.rs | 40 +- crates/ty_python_semantic/src/types/narrow.rs | 398 ++++++++++++------ 2 files changed, 280 insertions(+), 158 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 4b7626d5ff73e1..8186e3d3073f4b 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -741,11 +741,12 @@ impl ReachabilityConstraintsBuilder { /// AND a new optional narrowing constraint with an accumulated one. fn accumulate_constraint<'db>( + db: &'db dyn Db, accumulated: Option>, new: Option>, ) -> Option> { match (accumulated, new) { - (Some(acc), Some(new_c)) => Some(new_c.merge_constraint_and(acc)), + (Some(acc), Some(new_c)) => Some(new_c.merge_constraint_and(db, acc)), (None, Some(new_c)) => Some(new_c), (Some(acc), None) => Some(acc), (None, None) => None, @@ -753,7 +754,7 @@ fn accumulate_constraint<'db>( } /// Reachability-gated narrowing constraints -#[derive(Clone)] +#[derive(Debug, Clone, Copy)] enum GatedNarrowingConstraint<'db> { Unreachable, Reachable(Option>), @@ -762,6 +763,7 @@ enum GatedNarrowingConstraint<'db> { impl<'db> GatedNarrowingConstraint<'db> { fn merge_constraint_or( self, + db: &'db dyn Db, other: GatedNarrowingConstraint<'db>, ) -> GatedNarrowingConstraint<'db> { match (self, other) { @@ -776,11 +778,11 @@ impl<'db> GatedNarrowingConstraint<'db> { GatedNarrowingConstraint::Reachable(right_constraint), ) => { let left_constraint = left_constraint - .unwrap_or_else(|| NarrowingConstraint::intersection(Type::object())); + .unwrap_or_else(|| NarrowingConstraint::intersection(db, Type::object())); let right_constraint = right_constraint - .unwrap_or_else(|| NarrowingConstraint::intersection(Type::object())); + .unwrap_or_else(|| NarrowingConstraint::intersection(db, Type::object())); GatedNarrowingConstraint::Reachable(Some( - left_constraint.merge_constraint_or(right_constraint), + left_constraint.merge_constraint_or(db, right_constraint), )) } } @@ -848,8 +850,8 @@ impl ReachabilityConstraints { match constraint { GatedNarrowingConstraint::Unreachable => Type::Never, GatedNarrowingConstraint::Reachable(Some(constraint)) => { - NarrowingConstraint::intersection(base_ty) - .merge_constraint_and(constraint) + NarrowingConstraint::intersection(db, base_ty) + .merge_constraint_and(db, constraint) .evaluate_constraint_type(db) } GatedNarrowingConstraint::Reachable(None) => base_ty, @@ -879,9 +881,9 @@ impl ReachabilityConstraints { ALWAYS_TRUE | AMBIGUOUS => ALWAYS_TRUE, _ => id, }; - let key = (memo_id, accumulated.clone()); + let key = (memo_id, accumulated); if let Some(cached) = memo.get(&key) { - return cached.clone(); + return *cached; } let constraint = match id { @@ -937,12 +939,12 @@ impl ReachabilityConstraints { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { let constraint = get_constraint!(node.if_true, accumulated); - memo.insert(key, constraint.clone()); + memo.insert(key, constraint); return constraint; } Truthiness::AlwaysFalse => { let constraint = get_constraint!(node.if_false, accumulated); - memo.insert(key, constraint.clone()); + memo.insert(key, constraint); return constraint; } Truthiness::Ambiguous => {} @@ -951,33 +953,33 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { - let false_accumulated = accumulate_constraint(accumulated, neg_constraint); + let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let constraint = get_constraint!(node.if_false, false_accumulated); - memo.insert(key, constraint.clone()); + memo.insert(key, constraint); return constraint; } // If the false branch is statically unreachable, skip it entirely. if node.if_false == ALWAYS_FALSE { - let true_accumulated = accumulate_constraint(accumulated, pos_constraint); + let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); let constraint = get_constraint!(node.if_true, true_accumulated); - memo.insert(key, constraint.clone()); + memo.insert(key, constraint); return constraint; } // True branch: predicate holds → accumulate positive narrowing - let true_accumulated = accumulate_constraint(accumulated.clone(), pos_constraint); + let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); let true_constraint = get_constraint!(node.if_true, true_accumulated); // False branch: predicate doesn't hold → accumulate negative narrowing - let false_accumulated = accumulate_constraint(accumulated, neg_constraint); + let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_constraint = get_constraint!(node.if_false, false_accumulated); - true_constraint.merge_constraint_or(false_constraint) + true_constraint.merge_constraint_or(db, false_constraint) } }; - memo.insert(key, constraint.clone()); + memo.insert(key, constraint); constraint } diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index c3485c2fb682ef..a04de61202e6ca 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -28,9 +28,10 @@ use super::UnionType; use itertools::Itertools; use ruff_python_ast as ast; use ruff_python_ast::{BoolOp, ExprBoolOp}; -use rustc_hash::{FxHashMap, FxHashSet}; +use rustc_hash::{FxHashMap, FxHashSet, FxHasher}; use smallvec::{SmallVec, smallvec, smallvec_inline}; use std::collections::hash_map::Entry; +use std::hash::{Hash, Hasher}; /// A set of places that could possibly be narrowed by a predicate. /// @@ -69,10 +70,26 @@ pub(crate) fn infer_narrowing_constraint<'db>( PredicateNode::StarImportPlaceholder(_) => return None, }; - constraints.and_then(|constraints| constraints.get(place, predicate.is_positive).cloned()) + constraints.and_then(|constraints| constraints.get(db, place, predicate.is_positive)) } #[derive(Default, PartialEq, Debug, Eq, Clone, salsa::Update, get_size2::GetSize)] +struct PerPlaceDualNarrowingConstraintBuilder<'db> { + positive: Option>, + negative: Option>, +} + +type DualNarrowingConstraintsBuilderMap<'db> = + FxHashMap>; + +#[derive(Default, PartialEq, Debug, Eq, Clone, salsa::Update, get_size2::GetSize)] +struct DualNarrowingConstraintsBuilder<'db> { + by_place: DualNarrowingConstraintsBuilderMap<'db>, + has_positive: bool, + has_negative: bool, +} + +#[derive(Default, PartialEq, Debug, Eq, Clone, Hash, salsa::Update, get_size2::GetSize)] struct PerPlaceDualNarrowingConstraint<'db> { positive: Option>, negative: Option>, @@ -82,18 +99,45 @@ type DualNarrowingConstraintsMap<'db> = FxHashMap>; #[derive(Default, PartialEq, Debug, Eq, Clone, salsa::Update, get_size2::GetSize)] -struct DualNarrowingConstraints<'db> { +struct DualNarrowingConstraintsPayload<'db> { by_place: DualNarrowingConstraintsMap<'db>, has_positive: bool, has_negative: bool, } -impl<'db> DualNarrowingConstraints<'db> { +impl Hash for DualNarrowingConstraintsPayload<'_> { + fn hash(&self, state: &mut H) { + self.has_positive.hash(state); + self.has_negative.hash(state); + self.by_place.len().hash(state); + + // HashMap iteration order is unstable, so compute an order-independent aggregate hash. + let mut entries_hash = 0_u64; + for (place, constraints) in &self.by_place { + let mut hasher = FxHasher::default(); + place.hash(&mut hasher); + constraints.hash(&mut hasher); + entries_hash ^= hasher.finish(); + } + entries_hash.hash(state); + } +} + +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +struct DualNarrowingConstraints<'db> { + #[returns(ref)] + data: DualNarrowingConstraintsPayload<'db>, +} + +// The Salsa heap is tracked separately. +impl get_size2::GetSize for DualNarrowingConstraints<'_> {} + +impl<'db> DualNarrowingConstraintsBuilder<'db> { fn from_sides( - positive: Option>, - negative: Option>, + positive: Option>, + negative: Option>, ) -> Self { - let mut by_place = DualNarrowingConstraintsMap::default(); + let mut by_place = DualNarrowingConstraintsBuilderMap::default(); let has_positive = positive.is_some(); let has_negative = negative.is_some(); @@ -119,8 +163,8 @@ impl<'db> DualNarrowingConstraints<'db> { fn into_sides( self, ) -> ( - Option>, - Option>, + Option>, + Option>, ) { let mut positive = self.has_positive.then(FxHashMap::default); let mut negative = self.has_negative.then(FxHashMap::default); @@ -137,20 +181,6 @@ impl<'db> DualNarrowingConstraints<'db> { (positive, negative) } - fn get(&self, place: ScopedPlaceId, is_positive: bool) -> Option<&NarrowingConstraint<'db>> { - if is_positive && !self.has_positive || !is_positive && !self.has_negative { - return None; - } - - self.by_place.get(&place).and_then(|constraints| { - if is_positive { - constraints.positive.as_ref() - } else { - constraints.negative.as_ref() - } - }) - } - fn shrink_to_fit(&mut self) { self.by_place.shrink_to_fit(); } @@ -162,6 +192,50 @@ impl<'db> DualNarrowingConstraints<'db> { } self } + + fn finish(self, db: &'db dyn Db) -> DualNarrowingConstraints<'db> { + let mut by_place = DualNarrowingConstraintsMap::default(); + for (place, constraints) in self.by_place { + by_place.insert( + place, + PerPlaceDualNarrowingConstraint { + positive: constraints.positive.map(|constraint| constraint.finish(db)), + negative: constraints.negative.map(|constraint| constraint.finish(db)), + }, + ); + } + + DualNarrowingConstraints::new( + db, + DualNarrowingConstraintsPayload { + by_place, + has_positive: self.has_positive, + has_negative: self.has_negative, + }, + ) + } +} + +impl<'db> DualNarrowingConstraints<'db> { + fn get( + self, + db: &'db dyn Db, + place: ScopedPlaceId, + is_positive: bool, + ) -> Option> { + let data = self.data(db); + if is_positive && !data.has_positive || !is_positive && !data.has_negative { + return None; + } + + data.by_place.get(&place).and_then(|constraints| { + if is_positive { + constraints.positive + } else { + constraints.negative + } + }) + } } #[allow(clippy::unnecessary_wraps)] @@ -177,7 +251,7 @@ fn all_narrowing_constraints_for_expression<'db>( let module = parsed_module(db, expression.file(db)).load(db); Some( NarrowingConstraintsBuilder::new(db, &module, PredicateNode::Expression(expression)) - .finish(), + .finish(db), ) } @@ -188,7 +262,7 @@ fn all_narrowing_constraints_for_pattern<'db>( pattern: PatternPredicate<'db>, ) -> Option> { let module = parsed_module(db, pattern.file(db)).load(db); - Some(NarrowingConstraintsBuilder::new(db, &module, PredicateNode::Pattern(pattern)).finish()) + Some(NarrowingConstraintsBuilder::new(db, &module, PredicateNode::Pattern(pattern)).finish(db)) } /// Functions that can be used to narrow the type of a first argument using a "classinfo" second argument. @@ -344,26 +418,27 @@ impl<'db> Conjunctions<'db> { Self { conjuncts } } - fn and_with(mut self, other: Self) -> Self { + fn and_with(&self, other: &Self) -> Self { if self.conjuncts.iter().any(Type::is_never) || other.conjuncts.iter().any(Type::is_never) { return Self::singleton(Type::Never); } - for conjunct in other.conjuncts { - if !self.conjuncts.contains(&conjunct) { - self.conjuncts.push(conjunct); + let mut conjuncts = self.conjuncts.clone(); + for conjunct in other.conjuncts.iter().copied() { + if !conjuncts.contains(&conjunct) { + conjuncts.push(conjunct); } } - self + Self { conjuncts } } - fn evaluate_constraint_type(self, db: &'db dyn Db) -> Type<'db> { + fn evaluate_constraint_type(&self, db: &'db dyn Db) -> Type<'db> { if self.conjuncts.len() == 1 { return self.conjuncts[0]; } let mut intersection = IntersectionBuilder::new(db); - for conjunct in self.conjuncts { + for conjunct in self.conjuncts.iter().copied() { intersection = intersection.add_positive(conjunct); } intersection.build() @@ -391,7 +466,7 @@ impl<'db> Conjunctions<'db> { /// => `NarrowingConstraint { intersection_disjuncts: [A], replacement_disjuncts: [B] }` /// => evaluates to `(P & A) | B`, where `P` is our previously-known type #[derive(Hash, PartialEq, Debug, Eq, Clone, salsa::Update, get_size2::GetSize)] -pub(crate) struct NarrowingConstraint<'db> { +pub(crate) struct NarrowingConstraintBuilder<'db> { /// Intersection constraint (from `isinstance()` narrowing comparisons, `TypeIs`, and /// similar). We keep these as a disjunction of conjunctions to avoid constructing /// union/intersection types while merging constraints. @@ -405,10 +480,23 @@ pub(crate) struct NarrowingConstraint<'db> { replacement_disjuncts: SmallVec<[Conjunctions<'db>; 1]>, } -impl<'db> NarrowingConstraint<'db> { +#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)] +pub(crate) struct NarrowingConstraint<'db> { + #[returns(ref)] + inner: NarrowingConstraintBuilder<'db>, +} + +// The Salsa heap is tracked separately. +impl get_size2::GetSize for NarrowingConstraint<'_> {} + +impl<'db> NarrowingConstraintBuilder<'db> { + fn finish(self, db: &'db dyn Db) -> NarrowingConstraint<'db> { + NarrowingConstraint::new(db, self) + } + /// Create an "intersection" constraint: the previous type will be /// intersected with this constraint - pub(crate) fn intersection(constraint: Type<'db>) -> Self { + fn intersection(constraint: Type<'db>) -> Self { Self { intersection_disjuncts: smallvec_inline![Conjunctions::singleton(constraint)], replacement_disjuncts: smallvec![], @@ -426,7 +514,7 @@ impl<'db> NarrowingConstraint<'db> { /// Merge two constraints, taking their intersection but respecting "replacement" semantics (with /// `other` winning) - pub(crate) fn merge_constraint_and(&self, other: Self) -> Self { + fn merge_constraint_and(&self, other: &Self) -> Self { // Distribute AND over OR: (A1 | A2 | ...) AND (B1 | B2 | ...) // becomes (A1 & B1) | (A1 & B2) | ... | (A2 & B1) | ... // @@ -441,15 +529,13 @@ impl<'db> NarrowingConstraint<'db> { // and intersected with each LHS `replacement_disjunct` to form new additional // `replacement_disjuncts`. if other.intersection_disjuncts.is_empty() { - return other; + return other.clone(); } - let mut new_intersection_disjuncts = SmallVec::new(); + let mut new_intersection_disjuncts: SmallVec<[Conjunctions<'db>; 1]> = SmallVec::new(); for intersection_disjunct in &self.intersection_disjuncts { for other_intersection_disjunct in &other.intersection_disjuncts { - let merged = intersection_disjunct - .clone() - .and_with(other_intersection_disjunct.clone()); + let merged = intersection_disjunct.and_with(other_intersection_disjunct); if !new_intersection_disjuncts.contains(&merged) { new_intersection_disjuncts.push(merged); } @@ -460,43 +546,44 @@ impl<'db> NarrowingConstraint<'db> { SmallVec::new(); for replacement_disjunct in &self.replacement_disjuncts { for other_intersection_disjunct in &other.intersection_disjuncts { - let merged = replacement_disjunct - .clone() - .and_with(other_intersection_disjunct.clone()); + let merged = replacement_disjunct.and_with(other_intersection_disjunct); if !additional_replacement_disjuncts.contains(&merged) { additional_replacement_disjuncts.push(merged); } } } - let mut new_replacement_disjuncts = other.replacement_disjuncts; + let mut new_replacement_disjuncts = other.replacement_disjuncts.clone(); new_replacement_disjuncts.extend(additional_replacement_disjuncts); - NarrowingConstraint { + NarrowingConstraintBuilder { intersection_disjuncts: new_intersection_disjuncts, replacement_disjuncts: new_replacement_disjuncts, } } /// Merge two constraints with OR semantics (union/disjunction). - pub(crate) fn merge_constraint_or(mut self, other: Self) -> Self { - self.intersection_disjuncts - .extend(other.intersection_disjuncts); - self.replacement_disjuncts - .extend(other.replacement_disjuncts); - self + fn merge_constraint_or(&self, other: &Self) -> Self { + let mut intersection_disjuncts = self.intersection_disjuncts.clone(); + intersection_disjuncts.extend(other.intersection_disjuncts.iter().cloned()); + let mut replacement_disjuncts = self.replacement_disjuncts.clone(); + replacement_disjuncts.extend(other.replacement_disjuncts.iter().cloned()); + Self { + intersection_disjuncts, + replacement_disjuncts, + } } /// Evaluate the type this effectively constrains to /// /// Forgets whether each constraint originated from a `replacement` disjunct or not - pub(crate) fn evaluate_constraint_type(self, db: &'db dyn Db) -> Type<'db> { + fn evaluate_constraint_type(&self, db: &'db dyn Db) -> Type<'db> { let mut union = UnionBuilder::new(db); for conjunctions in self .replacement_disjuncts - .into_iter() - .chain(self.intersection_disjuncts) + .iter() + .chain(self.intersection_disjuncts.iter()) { union = union.add(conjunctions.evaluate_constraint_type(db)); } @@ -504,13 +591,35 @@ impl<'db> NarrowingConstraint<'db> { } } -impl<'db> From> for NarrowingConstraint<'db> { - fn from(constraint: Type<'db>) -> Self { - Self::intersection(constraint) +impl<'db> NarrowingConstraint<'db> { + pub(crate) fn intersection(db: &'db dyn Db, constraint: Type<'db>) -> Self { + NarrowingConstraintBuilder::intersection(constraint).finish(db) + } + + pub(crate) fn merge_constraint_and(self, db: &'db dyn Db, other: Self) -> Self { + if self == other { + return self; + } + self.inner(db) + .merge_constraint_and(other.inner(db)) + .finish(db) + } + + pub(crate) fn merge_constraint_or(self, db: &'db dyn Db, other: Self) -> Self { + if self == other { + return self; + } + self.inner(db) + .merge_constraint_or(other.inner(db)) + .finish(db) + } + + pub(crate) fn evaluate_constraint_type(self, db: &'db dyn Db) -> Type<'db> { + self.inner(db).evaluate_constraint_type(db) } } -type NarrowingConstraints<'db> = FxHashMap>; +type NarrowingConstraintBuilders<'db> = FxHashMap>; /// Merge constraints with AND semantics (intersection/conjunction). /// @@ -522,15 +631,15 @@ type NarrowingConstraints<'db> = FxHashMap( - into: &mut NarrowingConstraints<'db>, - from: NarrowingConstraints<'db>, + into: &mut NarrowingConstraintBuilders<'db>, + from: NarrowingConstraintBuilders<'db>, ) { for (key, from_constraint) in from { match into.entry(key) { Entry::Occupied(mut entry) => { let into_constraint = entry.get(); - entry.insert(into_constraint.merge_constraint_and(from_constraint)); + entry.insert(into_constraint.merge_constraint_and(&from_constraint)); } Entry::Vacant(entry) => { entry.insert(from_constraint); @@ -547,8 +656,8 @@ fn merge_constraints_and<'db>( /// However, if a place appears in only one branch of the OR, we need to widen it /// to `object` in the overall result (because the other branch doesn't constrain it). fn merge_constraints_or<'db>( - into: &mut NarrowingConstraints<'db>, - from: NarrowingConstraints<'db>, + into: &mut NarrowingConstraintBuilders<'db>, + from: NarrowingConstraintBuilders<'db>, ) { // For places that appear in `into` but not in `from`, widen to object into.retain(|key, _| from.contains_key(key)); @@ -622,24 +731,27 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { } } - fn finish(mut self) -> DualNarrowingConstraints<'db> { + fn finish(mut self, db: &'db dyn Db) -> DualNarrowingConstraints<'db> { let mut constraints = match self.predicate { PredicateNode::Expression(expression) => self.evaluate_expression_predicate(expression), PredicateNode::Pattern(pattern) => self.evaluate_pattern_predicate(pattern), PredicateNode::ReturnsNever(_) | PredicateNode::StarImportPlaceholder(_) => { - return DualNarrowingConstraints::default(); + return DualNarrowingConstraints::new( + db, + DualNarrowingConstraintsPayload::default(), + ); } }; constraints.shrink_to_fit(); - constraints + constraints.finish(db) } fn merge_constraints_and_sequence( - sub_constraints: Vec>>, - ) -> Option> { - let mut aggregation: Option> = None; + sub_constraints: Vec>>, + ) -> Option> { + let mut aggregation: Option> = None; for sub_constraint in sub_constraints.into_iter().flatten() { if let Some(ref mut some_aggregation) = aggregation { merge_constraints_and(some_aggregation, sub_constraint); @@ -651,8 +763,8 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { } fn merge_constraints_or_sequence( - sub_constraints: Vec>>, - ) -> Option> { + sub_constraints: Vec>>, + ) -> Option> { let (mut first, rest) = { let mut it = sub_constraints.into_iter(); (it.next()?, it) @@ -673,7 +785,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { fn evaluate_expression_predicate( &mut self, expression: Expression<'db>, - ) -> DualNarrowingConstraints<'db> { + ) -> DualNarrowingConstraintsBuilder<'db> { let expression_node = expression.node_ref(self.db, self.module); self.evaluate_expression_node_predicate(expression_node, expression) } @@ -682,7 +794,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, expression_node: &ruff_python_ast::Expr, expression: Expression<'db>, - ) -> DualNarrowingConstraints<'db> { + ) -> DualNarrowingConstraintsBuilder<'db> { match expression_node { ast::Expr::Name(_) | ast::Expr::Attribute(_) | ast::Expr::Subscript(_) => { self.evaluate_simple_expr(expression_node) @@ -696,7 +808,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { .swap_polarity(), ast::Expr::BoolOp(bool_op) => self.evaluate_bool_op(bool_op, expression), ast::Expr::Named(expr_named) => self.evaluate_expr_named(expr_named), - _ => DualNarrowingConstraints::default(), + _ => DualNarrowingConstraintsBuilder::default(), } } @@ -704,7 +816,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, pattern_predicate_kind: &PatternPredicateKind<'db>, subject: Expression<'db>, - ) -> DualNarrowingConstraints<'db> { + ) -> DualNarrowingConstraintsBuilder<'db> { match pattern_predicate_kind { PatternPredicateKind::Singleton(singleton) => { self.evaluate_match_pattern_singleton(subject, *singleton) @@ -718,17 +830,17 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { } PatternPredicateKind::As(pattern, _) => pattern .as_deref() - .map_or_else(DualNarrowingConstraints::default, |p| { + .map_or_else(DualNarrowingConstraintsBuilder::default, |p| { self.evaluate_pattern_predicate_kind(p, subject) }), - PatternPredicateKind::Unsupported => DualNarrowingConstraints::default(), + PatternPredicateKind::Unsupported => DualNarrowingConstraintsBuilder::default(), } } fn evaluate_pattern_predicate( &mut self, pattern: PatternPredicate<'db>, - ) -> DualNarrowingConstraints<'db> { + ) -> DualNarrowingConstraintsBuilder<'db> { self.evaluate_pattern_predicate_kind(pattern.kind(self.db), pattern.subject(self.db)) } @@ -841,28 +953,28 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { } } - fn evaluate_simple_expr(&mut self, expr: &ast::Expr) -> DualNarrowingConstraints<'db> { + fn evaluate_simple_expr(&mut self, expr: &ast::Expr) -> DualNarrowingConstraintsBuilder<'db> { let Some(target) = PlaceExpr::try_from_expr(expr) else { - return DualNarrowingConstraints::default(); + return DualNarrowingConstraintsBuilder::default(); }; let place = self.expect_place(&target); - let positive = NarrowingConstraints::from_iter([( + let positive = NarrowingConstraintBuilders::from_iter([( place, - NarrowingConstraint::intersection(Type::AlwaysFalsy.negate(self.db)), + NarrowingConstraintBuilder::intersection(Type::AlwaysFalsy.negate(self.db)), )]); - let negative = NarrowingConstraints::from_iter([( + let negative = NarrowingConstraintBuilders::from_iter([( place, - NarrowingConstraint::intersection(Type::AlwaysTruthy.negate(self.db)), + NarrowingConstraintBuilder::intersection(Type::AlwaysTruthy.negate(self.db)), )]); - DualNarrowingConstraints::from_sides(Some(positive), Some(negative)) + DualNarrowingConstraintsBuilder::from_sides(Some(positive), Some(negative)) } fn evaluate_expr_named( &mut self, expr_named: &ast::ExprNamed, - ) -> DualNarrowingConstraints<'db> { + ) -> DualNarrowingConstraintsBuilder<'db> { self.evaluate_simple_expr(&expr_named.target) } @@ -1118,9 +1230,9 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, expr_compare: &ast::ExprCompare, expression: Expression<'db>, - ) -> DualNarrowingConstraints<'db> { + ) -> DualNarrowingConstraintsBuilder<'db> { let inference = infer_expression_types(self.db, expression, TypeContext::default()); - DualNarrowingConstraints::from_sides( + DualNarrowingConstraintsBuilder::from_sides( self.evaluate_expr_compare_for_polarity(expr_compare, inference, true), self.evaluate_expr_compare_for_polarity(expr_compare, inference, false), ) @@ -1131,7 +1243,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { expr_compare: &ast::ExprCompare, inference: &ExpressionInference<'db>, is_positive: bool, - ) -> Option> { + ) -> Option> { fn is_narrowing_target_candidate(expr: &ast::Expr) -> bool { matches!( expr, @@ -1207,7 +1319,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { let comparator_tuples = std::iter::once(&**left) .chain(comparators) .tuple_windows::<(&ruff_python_ast::Expr, &ruff_python_ast::Expr)>(); - let mut constraints = NarrowingConstraints::default(); + let mut constraints = NarrowingConstraintBuilders::default(); // Narrow unions of tuples based on element checks. For example: // @@ -1242,7 +1354,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { }); if filtered != Type::Union(union) { let place = self.expect_place(&subscript_place_expr); - constraints.insert(place, NarrowingConstraint::replacement(filtered)); + constraints.insert(place, NarrowingConstraintBuilder::replacement(filtered)); } } @@ -1277,7 +1389,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { constraints .entry(place) .and_modify(|existing| { - *existing = existing.merge_constraint_and(constraint.clone()); + *existing = existing.merge_constraint_and(&constraint); }) .or_insert(constraint); } else if let Some((place, constraint)) = self.narrow_tuple_subscript( @@ -1290,7 +1402,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { constraints .entry(place) .and_modify(|existing| { - *existing = existing.merge_constraint_and(constraint.clone()); + *existing = existing.merge_constraint_and(&constraint); }) .or_insert(constraint); } @@ -1371,7 +1483,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { if narrowed != resolved_rhs_type { let place = self.expect_place(&rhs_place_expr); - constraints.insert(place, NarrowingConstraint::replacement(narrowed)); + constraints.insert(place, NarrowingConstraintBuilder::replacement(narrowed)); } } } @@ -1426,7 +1538,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { let place = self.expect_place(&target); constraints.insert( place, - NarrowingConstraint::intersection( + NarrowingConstraintBuilder::intersection( Type::instance(self.db, other_class.top_materialization(self.db)) .negate_if(self.db, !is_positive), ), @@ -1452,11 +1564,11 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { ) { let place = self.expect_place(&narrowable); - let constraint = NarrowingConstraint::intersection(ty); + let constraint = NarrowingConstraintBuilder::intersection(ty); constraints .entry(place) .and_modify(|existing| { - *existing = existing.merge_constraint_and(constraint.clone()); + *existing = existing.merge_constraint_and(&constraint); }) .or_insert(constraint); } @@ -1478,11 +1590,11 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { ) { let place = self.expect_place(&narrowable); - let constraint = NarrowingConstraint::intersection(ty); + let constraint = NarrowingConstraintBuilder::intersection(ty); constraints .entry(place) .and_modify(|existing| { - *existing = existing.merge_constraint_and(constraint.clone()); + *existing = existing.merge_constraint_and(&constraint); }) .or_insert(constraint); @@ -1499,7 +1611,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, expr_call: &ast::ExprCall, expression: Expression<'db>, - ) -> DualNarrowingConstraints<'db> { + ) -> DualNarrowingConstraintsBuilder<'db> { let inference = infer_expression_types(self.db, expression, TypeContext::default()); // If the return type of expr_call is TypeGuard (positive) / TypeIs: @@ -1508,7 +1620,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { { let negative_constraints = self.evaluate_type_guard_call_for_polarity(inference, expr_call, false); - return DualNarrowingConstraints::from_sides( + return DualNarrowingConstraintsBuilder::from_sides( Some(positive_constraints), negative_constraints, ); @@ -1525,7 +1637,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { .evaluate_expression_node_predicate(&expr_call.arguments.args[0], expression); } - DualNarrowingConstraints::from_sides( + DualNarrowingConstraintsBuilder::from_sides( self.evaluate_expr_call_for_polarity(expr_call, inference, callable_ty, true), self.evaluate_expr_call_for_polarity(expr_call, inference, callable_ty, false), ) @@ -1537,7 +1649,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { inference: &ExpressionInference<'db>, callable_ty: Type<'db>, is_positive: bool, - ) -> Option> { + ) -> Option> { match callable_ty { // For the expression `len(E)`, we narrow the type based on whether len(E) is truthy // (i.e., whether E is non-empty). We only narrow the parts of the type where we know @@ -1555,9 +1667,9 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { if let Some(narrowed_ty) = Self::narrow_type_by_len(self.db, arg_ty, is_positive) { let target = PlaceExpr::try_from_expr(arg)?; let place = self.expect_place(&target); - Some(NarrowingConstraints::from_iter([( + Some(NarrowingConstraintBuilders::from_iter([( place, - NarrowingConstraint::intersection(narrowed_ty), + NarrowingConstraintBuilder::intersection(narrowed_ty), )])) } else { None @@ -1586,9 +1698,9 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { let constraint = Type::protocol_with_readonly_members(self.db, [(attr, Type::object())]); - return Some(NarrowingConstraints::from_iter([( + return Some(NarrowingConstraintBuilders::from_iter([( place, - NarrowingConstraint::intersection( + NarrowingConstraintBuilder::intersection( constraint.negate_if(self.db, !is_positive), ), )])); @@ -1601,9 +1713,9 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { function .generate_constraint(self.db, class_info_ty) .map(|constraint| { - NarrowingConstraints::from_iter([( + NarrowingConstraintBuilders::from_iter([( place, - NarrowingConstraint::intersection( + NarrowingConstraintBuilder::intersection( constraint.negate_if(self.db, !is_positive), ), )]) @@ -1620,7 +1732,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { inference: &ExpressionInference<'db>, expr_call: &ast::ExprCall, is_positive: bool, - ) -> Option> { + ) -> Option> { let return_ty = inference.expression_type(expr_call); let place_and_constraint = match return_ty { @@ -1628,7 +1740,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { let (_, place) = type_is.place_info(self.db)?; Some(( place, - NarrowingConstraint::intersection( + NarrowingConstraintBuilder::intersection( type_is .return_type(self.db) .negate_if(self.db, !is_positive), @@ -1640,21 +1752,23 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { let (_, place) = type_guard.place_info(self.db)?; Some(( place, - NarrowingConstraint::replacement(type_guard.return_type(self.db)), + NarrowingConstraintBuilder::replacement(type_guard.return_type(self.db)), )) } _ => None, }?; - Some(NarrowingConstraints::from_iter([place_and_constraint])) + Some(NarrowingConstraintBuilders::from_iter([ + place_and_constraint, + ])) } fn evaluate_match_pattern_singleton( &mut self, subject: Expression<'db>, singleton: ast::Singleton, - ) -> DualNarrowingConstraints<'db> { - DualNarrowingConstraints::from_sides( + ) -> DualNarrowingConstraintsBuilder<'db> { + DualNarrowingConstraintsBuilder::from_sides( self.evaluate_match_pattern_singleton_for_polarity(subject, singleton, true), self.evaluate_match_pattern_singleton_for_polarity(subject, singleton, false), ) @@ -1665,7 +1779,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { subject: Expression<'db>, singleton: ast::Singleton, is_positive: bool, - ) -> Option> { + ) -> Option> { let subject = PlaceExpr::try_from_expr(subject.node_ref(self.db, self.module))?; let place = self.expect_place(&subject); @@ -1675,9 +1789,9 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { ast::Singleton::False => Type::BooleanLiteral(false), }; let ty = ty.negate_if(self.db, !is_positive); - Some(NarrowingConstraints::from_iter([( + Some(NarrowingConstraintBuilders::from_iter([( place, - NarrowingConstraint::intersection(ty), + NarrowingConstraintBuilder::intersection(ty), )])) } @@ -1686,8 +1800,8 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { subject: Expression<'db>, cls: Expression<'db>, kind: ClassPatternKind, - ) -> DualNarrowingConstraints<'db> { - DualNarrowingConstraints::from_sides( + ) -> DualNarrowingConstraintsBuilder<'db> { + DualNarrowingConstraintsBuilder::from_sides( self.evaluate_match_pattern_class_for_polarity(subject, cls, kind, true), self.evaluate_match_pattern_class_for_polarity(subject, cls, kind, false), ) @@ -1699,7 +1813,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { cls: Expression<'db>, kind: ClassPatternKind, is_positive: bool, - ) -> Option> { + ) -> Option> { if !kind.is_irrefutable() && !is_positive { // A class pattern like `case Point(x=0, y=0)` is not irrefutable. In the positive case, // we can still narrow the type of the match subject to `Point`. But in the negative case, @@ -1722,9 +1836,9 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { _ => return None, }; - Some(NarrowingConstraints::from_iter([( + Some(NarrowingConstraintBuilders::from_iter([( place, - NarrowingConstraint::intersection(narrowed_type), + NarrowingConstraintBuilder::intersection(narrowed_type), )])) } @@ -1732,8 +1846,8 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, subject: Expression<'db>, value: Expression<'db>, - ) -> DualNarrowingConstraints<'db> { - DualNarrowingConstraints::from_sides( + ) -> DualNarrowingConstraintsBuilder<'db> { + DualNarrowingConstraintsBuilder::from_sides( self.evaluate_match_pattern_value_for_polarity(subject, value, true), self.evaluate_match_pattern_value_for_polarity(subject, value, false), ) @@ -1744,7 +1858,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { subject: Expression<'db>, value: Expression<'db>, is_positive: bool, - ) -> Option> { + ) -> Option> { let subject_node = subject.node_ref(self.db, self.module); let place = { let subject = PlaceExpr::try_from_expr(subject_node)?; @@ -1767,7 +1881,10 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { }, ) .map(|ty| { - NarrowingConstraints::from_iter([(place, NarrowingConstraint::intersection(ty))]) + NarrowingConstraintBuilders::from_iter([( + place, + NarrowingConstraintBuilder::intersection(ty), + )]) }) .unwrap_or_default(); @@ -1813,9 +1930,9 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { &mut self, subject: Expression<'db>, predicates: &Vec>, - ) -> DualNarrowingConstraints<'db> { - let mut positive: Option> = None; - let mut negative: Option> = None; + ) -> DualNarrowingConstraintsBuilder<'db> { + let mut positive: Option> = None; + let mut negative: Option> = None; for predicate in predicates { let (sub_positive, sub_negative) = self @@ -1839,14 +1956,14 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { } } - DualNarrowingConstraints::from_sides(positive, negative) + DualNarrowingConstraintsBuilder::from_sides(positive, negative) } fn evaluate_bool_op( &mut self, expr_bool_op: &ExprBoolOp, expression: Expression<'db>, - ) -> DualNarrowingConstraints<'db> { + ) -> DualNarrowingConstraintsBuilder<'db> { let inference = infer_expression_types(self.db, expression, TypeContext::default()); let sub_constraints = expr_bool_op .values @@ -1865,7 +1982,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { let (positive_sub_constraints, negative_sub_constraints): (Vec<_>, Vec<_>) = sub_constraints .into_iter() - .map(DualNarrowingConstraints::into_sides) + .map(DualNarrowingConstraintsBuilder::into_sides) .unzip(); let (positive, negative) = match expr_bool_op.op { @@ -1879,7 +1996,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { ), }; - DualNarrowingConstraints::from_sides(positive, negative) + DualNarrowingConstraintsBuilder::from_sides(positive, negative) } /// Narrow tagged unions of `TypedDict`s with `Literal` keys. @@ -1896,7 +2013,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { subscript_key_type: Type<'db>, rhs_type: Type<'db>, constrain_with_equality: bool, - ) -> Option<(ScopedPlaceId, NarrowingConstraint<'db>)> { + ) -> Option<(ScopedPlaceId, NarrowingConstraintBuilder<'db>)> { // Check preconditions: we need a TypedDict, a string key, and a supported tag literal. if !is_or_contains_typeddict(self.db, subscript_value_type) { return None; @@ -1946,7 +2063,10 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { // As mentioned above, the synthesized `TypedDict` is always negated. let intersection = Type::TypedDict(synthesized_typeddict).negate(self.db); let place = self.expect_place(&subscript_place_expr); - Some((place, NarrowingConstraint::intersection(intersection))) + Some(( + place, + NarrowingConstraintBuilder::intersection(intersection), + )) } /// Narrow tagged unions of tuples with `Literal` elements. @@ -1970,7 +2090,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { subscript_index_type: Type<'db>, rhs_type: Type<'db>, constrain_with_equality: bool, - ) -> Option<(ScopedPlaceId, NarrowingConstraint<'db>)> { + ) -> Option<(ScopedPlaceId, NarrowingConstraintBuilder<'db>)> { // We need a union type for narrowing to be useful. let Type::Union(union) = subscript_value_type.resolve_type_alias(self.db) else { return None; @@ -2022,7 +2142,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { // Only create a constraint if we actually narrowed something. if filtered != Type::Union(union) { let place = self.expect_place(&subscript_place_expr); - Some((place, NarrowingConstraint::replacement(filtered))) + Some((place, NarrowingConstraintBuilder::replacement(filtered))) } else { None } From a596400c48640210bb26e5c63acbb473c76e7a62 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Thu, 19 Feb 2026 14:03:37 +0900 Subject: [PATCH 51/69] revert `add `GatedNarrowingConstraint`` --- .../reachability_constraints.rs | 111 +++++++----------- crates/ty_python_semantic/src/types.rs | 14 +++ .../ty_python_semantic/src/types/builder.rs | 5 + crates/ty_python_semantic/src/types/narrow.rs | 18 ++- 4 files changed, 73 insertions(+), 75 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 8b23a175c3deb2..e0dcef740bea90 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -753,42 +753,6 @@ fn accumulate_constraint<'db>( } } -/// Reachability-gated narrowing constraints -#[derive(Debug, Clone, Copy)] -enum GatedNarrowingConstraint<'db> { - Unreachable, - Reachable(Option>), -} - -impl<'db> GatedNarrowingConstraint<'db> { - fn merge_constraint_or( - self, - db: &'db dyn Db, - other: GatedNarrowingConstraint<'db>, - ) -> GatedNarrowingConstraint<'db> { - match (self, other) { - (GatedNarrowingConstraint::Unreachable, other) - | (other, GatedNarrowingConstraint::Unreachable) => other, - ( - GatedNarrowingConstraint::Reachable(None), - GatedNarrowingConstraint::Reachable(None), - ) => GatedNarrowingConstraint::Reachable(None), - ( - GatedNarrowingConstraint::Reachable(left_constraint), - GatedNarrowingConstraint::Reachable(right_constraint), - ) => { - let left_constraint = left_constraint - .unwrap_or_else(|| NarrowingConstraint::intersection(db, Type::object())); - let right_constraint = right_constraint - .unwrap_or_else(|| NarrowingConstraint::intersection(db, Type::object())); - GatedNarrowingConstraint::Reachable(Some( - left_constraint.merge_constraint_or(db, right_constraint), - )) - } - } - } -} - impl ReachabilityConstraints { /// Look up an interior node by its constraint ID. fn get_interior_node(&self, id: ScopedReachabilityConstraintId) -> InteriorNode { @@ -836,26 +800,22 @@ impl ReachabilityConstraints { ) -> Type<'db> { let mut memo = FxHashMap::default(); let mut truthiness_memo = FxHashMap::default(); - let constraint = self.narrow_by_constraint_inner( + let redundant_union = self.narrow_by_constraint_inner( db, predicates, predicate_place_versions, id, + base_ty, place, binding_place_version, None, &mut memo, &mut truthiness_memo, ); - match constraint { - GatedNarrowingConstraint::Unreachable => Type::Never, - GatedNarrowingConstraint::Reachable(Some(constraint)) => { - NarrowingConstraint::intersection(db, base_ty) - .merge_constraint_and(db, constraint) - .evaluate_constraint_type(db) - } - GatedNarrowingConstraint::Reachable(None) => base_ty, - } + UnionBuilder::new(db) + .unpack_aliases(false) + .add(redundant_union) + .build() } /// Inner recursive helper that accumulates narrowing constraints along each TDD path. @@ -866,15 +826,16 @@ impl ReachabilityConstraints { predicates: &Predicates<'db>, predicate_place_versions: &PredicatePlaceVersions, id: ScopedNarrowingConstraint, + base_ty: Type<'db>, place: ScopedPlaceId, binding_place_version: Option, accumulated: Option>, memo: &mut FxHashMap< (ScopedNarrowingConstraint, Option>), - GatedNarrowingConstraint<'db>, + Type<'db>, >, truthiness_memo: &mut FxHashMap, Truthiness>, - ) -> GatedNarrowingConstraint<'db> { + ) -> Type<'db> { // `ALWAYS_TRUE` and `AMBIGUOUS` are equivalent for narrowing purposes. // Canonicalize to improve memo hits across terminal leaves. let memo_id = match id { @@ -886,21 +847,28 @@ impl ReachabilityConstraints { return *cached; } - let constraint = match id { - // Return accumulated narrowing constraints and defer actual type construction - // until `narrow_by_constraint`. - ALWAYS_TRUE | AMBIGUOUS => GatedNarrowingConstraint::Reachable(accumulated), - ALWAYS_FALSE => GatedNarrowingConstraint::Unreachable, + let narrowed = match id { + ALWAYS_TRUE | AMBIGUOUS => { + // Apply all accumulated narrowing constraints to the base type + match accumulated { + Some(constraint) => NarrowingConstraint::intersection(db, base_ty) + .merge_constraint_and(db, constraint) + .evaluate_constraint_type(db, false), + None => base_ty, + } + } + ALWAYS_FALSE => Type::Never, _ => { let node = self.get_interior_node(id); let predicate = predicates[node.atom]; - macro_rules! get_constraint { + macro_rules! narrow { ($next_id:expr, $next_accumulated:expr) => { self.narrow_by_constraint_inner( db, predicates, predicate_place_versions, $next_id, + base_ty, place, binding_place_version, $next_accumulated, @@ -938,14 +906,14 @@ impl ReachabilityConstraints { if pos_constraint.is_none() && neg_constraint.is_none() { match Self::analyze_single_cached(db, predicate, truthiness_memo) { Truthiness::AlwaysTrue => { - let constraint = get_constraint!(node.if_true, accumulated); - memo.insert(key, constraint); - return constraint; + let narrowed = narrow!(node.if_true, accumulated); + memo.insert(key, narrowed); + return narrowed; } Truthiness::AlwaysFalse => { - let constraint = get_constraint!(node.if_false, accumulated); - memo.insert(key, constraint); - return constraint; + let narrowed = narrow!(node.if_false, accumulated); + memo.insert(key, narrowed); + return narrowed; } Truthiness::Ambiguous => {} } @@ -954,33 +922,34 @@ impl ReachabilityConstraints { // If the true branch is statically unreachable, skip it entirely. if node.if_true == ALWAYS_FALSE { let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); - let constraint = get_constraint!(node.if_false, false_accumulated); - memo.insert(key, constraint); - return constraint; + let narrowed = narrow!(node.if_false, false_accumulated); + memo.insert(key, narrowed); + return narrowed; } // If the false branch is statically unreachable, skip it entirely. if node.if_false == ALWAYS_FALSE { let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); - let constraint = get_constraint!(node.if_true, true_accumulated); - memo.insert(key, constraint); - return constraint; + let narrowed = narrow!(node.if_true, true_accumulated); + memo.insert(key, narrowed); + return narrowed; } // True branch: predicate holds → accumulate positive narrowing let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); - let true_constraint = get_constraint!(node.if_true, true_accumulated); + let true_ty = narrow!(node.if_true, true_accumulated); // False branch: predicate doesn't hold → accumulate negative narrowing let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); - let false_constraint = get_constraint!(node.if_false, false_accumulated); + let false_ty = narrow!(node.if_false, false_accumulated); - true_constraint.merge_constraint_or(db, false_constraint) + // We won't do a union type redundancy check here, as it only needs to be performed once for the final result. + UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]) } }; - memo.insert(key, constraint); - constraint + memo.insert(key, narrowed); + narrowed } fn predicate_applies_to_place_version( diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 9fea5bf06d481a..9df97e5ffa2928 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -12305,6 +12305,20 @@ impl<'db> UnionType<'db> { .build() } + pub(crate) fn from_elements_no_redundancy_check(db: &'db dyn Db, elements: I) -> Type<'db> + where + I: IntoIterator, + T: Into>, + { + elements + .into_iter() + .fold( + UnionBuilder::new(db).check_redundancy(false), + |builder, element| builder.add(element.into()), + ) + .build() + } + /// Create a union from a list of elements without unpacking type aliases. pub(crate) fn from_elements_leave_aliases(db: &'db dyn Db, elements: I) -> Type<'db> where diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 20369e30c39d9a..8c11873d98843f 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -378,6 +378,11 @@ impl<'db> UnionBuilder<'db> { self } + pub(crate) fn check_redundancy(mut self, val: bool) -> Self { + self.check_redundancy = val; + self + } + pub(crate) fn cycle_recovery(mut self, val: bool) -> Self { self.cycle_recovery = val; if self.cycle_recovery { diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 98b81d59b7609c..3a14b04364b6cd 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -573,8 +573,12 @@ impl<'db> NarrowingConstraintBuilder<'db> { /// Evaluate the type this effectively constrains to /// /// Forgets whether each constraint originated from a `replacement` disjunct or not - fn evaluate_constraint_type(&self, db: &'db dyn Db) -> Type<'db> { - let mut union = UnionBuilder::new(db); + pub(crate) fn evaluate_constraint_type( + &self, + db: &'db dyn Db, + check_redundancy: bool, + ) -> Type<'db> { + let mut union = UnionBuilder::new(db).check_redundancy(check_redundancy); for conjunctions in self .replacement_disjuncts .iter() @@ -600,6 +604,7 @@ impl<'db> NarrowingConstraint<'db> { .finish(db) } + #[allow(unused)] pub(crate) fn merge_constraint_or(self, db: &'db dyn Db, other: Self) -> Self { if self == other { return self; @@ -609,8 +614,13 @@ impl<'db> NarrowingConstraint<'db> { .finish(db) } - pub(crate) fn evaluate_constraint_type(self, db: &'db dyn Db) -> Type<'db> { - self.inner(db).evaluate_constraint_type(db) + pub(crate) fn evaluate_constraint_type( + self, + db: &'db dyn Db, + check_redundancy: bool, + ) -> Type<'db> { + self.inner(db) + .evaluate_constraint_type(db, check_redundancy) } } From fb05c74cd209d8ccf0c4553f5f486533aa83de72 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Thu, 19 Feb 2026 14:18:44 +0900 Subject: [PATCH 52/69] cache two types union as a tracked function --- .../reachability_constraints.rs | 5 ++--- crates/ty_python_semantic/src/types.rs | 22 +++++++++---------- .../ty_python_semantic/src/types/builder.rs | 11 +--------- crates/ty_python_semantic/src/types/narrow.rs | 17 ++++---------- 4 files changed, 17 insertions(+), 38 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index e0dcef740bea90..2fc966a06702d0 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -853,7 +853,7 @@ impl ReachabilityConstraints { match accumulated { Some(constraint) => NarrowingConstraint::intersection(db, base_ty) .merge_constraint_and(db, constraint) - .evaluate_constraint_type(db, false), + .evaluate_constraint_type(db), None => base_ty, } } @@ -943,8 +943,7 @@ impl ReachabilityConstraints { let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = narrow!(node.if_false, false_accumulated); - // We won't do a union type redundancy check here, as it only needs to be performed once for the final result. - UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]) + UnionType::from_two_elements(db, true_ty, false_ty) } }; diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 9df97e5ffa2928..cec6cfe115436d 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -12289,6 +12289,7 @@ pub(crate) fn walk_union<'db, V: visitor::TypeVisitor<'db> + ?Sized>( // The Salsa heap is tracked separately. impl get_size2::GetSize for UnionType<'_> {} +#[salsa::tracked] impl<'db> UnionType<'db> { /// Create a union from a list of elements /// (which may be eagerly simplified into a different variant of [`Type`] altogether). @@ -12305,18 +12306,15 @@ impl<'db> UnionType<'db> { .build() } - pub(crate) fn from_elements_no_redundancy_check(db: &'db dyn Db, elements: I) -> Type<'db> - where - I: IntoIterator, - T: Into>, - { - elements - .into_iter() - .fold( - UnionBuilder::new(db).check_redundancy(false), - |builder, element| builder.add(element.into()), - ) - .build() + #[salsa::tracked( + cycle_initial=|_, id, _, _| Type::divergent(id), + cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _, _| { + result.cycle_normalized(db, *previous, cycle) + }, + heap_size=ruff_memory_usage::heap_size + )] + pub(crate) fn from_two_elements(db: &'db dyn Db, a: Type<'db>, b: Type<'db>) -> Type<'db> { + UnionBuilder::new(db).add(a).add(b).build() } /// Create a union from a list of elements without unpacking type aliases. diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 8c11873d98843f..cab00e118c971f 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -341,13 +341,11 @@ const MAX_NON_RECURSIVE_UNION_LITERALS: usize = 256; /// if reachability analysis etc. fails when analysing these enums. const MAX_NON_RECURSIVE_UNION_ENUM_LITERALS: usize = 8192; -#[allow(clippy::struct_excessive_bools)] pub(crate) struct UnionBuilder<'db> { elements: Vec>, db: &'db dyn Db, unpack_aliases: bool, order_elements: bool, - check_redundancy: bool, /// This is enabled when joining types in a `cycle_recovery` function. /// Since a cycle cannot be created within a `cycle_recovery` function, /// execution of `is_redundant_with` is skipped. @@ -362,7 +360,6 @@ impl<'db> UnionBuilder<'db> { elements: vec![], unpack_aliases: true, order_elements: false, - check_redundancy: true, cycle_recovery: false, recursively_defined: RecursivelyDefined::No, } @@ -378,15 +375,9 @@ impl<'db> UnionBuilder<'db> { self } - pub(crate) fn check_redundancy(mut self, val: bool) -> Self { - self.check_redundancy = val; - self - } - pub(crate) fn cycle_recovery(mut self, val: bool) -> Self { self.cycle_recovery = val; if self.cycle_recovery { - self.check_redundancy = false; self.unpack_aliases = false; } self @@ -751,7 +742,7 @@ impl<'db> UnionBuilder<'db> { // If an alias gets here, it means we aren't unpacking aliases, and we also // shouldn't try to simplify aliases out of the union, because that will require // unpacking them. - let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && self.check_redundancy; + let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && !self.cycle_recovery; let mut ty_negated: Option = None; let mut to_remove = SmallVec::<[usize; 2]>::new(); diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 3a14b04364b6cd..6c1f4d559c0ef4 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -573,12 +573,8 @@ impl<'db> NarrowingConstraintBuilder<'db> { /// Evaluate the type this effectively constrains to /// /// Forgets whether each constraint originated from a `replacement` disjunct or not - pub(crate) fn evaluate_constraint_type( - &self, - db: &'db dyn Db, - check_redundancy: bool, - ) -> Type<'db> { - let mut union = UnionBuilder::new(db).check_redundancy(check_redundancy); + pub(crate) fn evaluate_constraint_type(&self, db: &'db dyn Db) -> Type<'db> { + let mut union = UnionBuilder::new(db); for conjunctions in self .replacement_disjuncts .iter() @@ -614,13 +610,8 @@ impl<'db> NarrowingConstraint<'db> { .finish(db) } - pub(crate) fn evaluate_constraint_type( - self, - db: &'db dyn Db, - check_redundancy: bool, - ) -> Type<'db> { - self.inner(db) - .evaluate_constraint_type(db, check_redundancy) + pub(crate) fn evaluate_constraint_type(self, db: &'db dyn Db) -> Type<'db> { + self.inner(db).evaluate_constraint_type(db) } } From f476d36aabf89c6157aacda3a8e06be2a8b8c723 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Thu, 19 Feb 2026 15:05:26 +0900 Subject: [PATCH 53/69] Revert "cache two types union as a tracked function" This reverts commit fb05c74cd209d8ccf0c4553f5f486533aa83de72. --- .../reachability_constraints.rs | 5 +++-- crates/ty_python_semantic/src/types.rs | 22 ++++++++++--------- .../ty_python_semantic/src/types/builder.rs | 11 +++++++++- crates/ty_python_semantic/src/types/narrow.rs | 17 ++++++++++---- 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 2fc966a06702d0..e0dcef740bea90 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -853,7 +853,7 @@ impl ReachabilityConstraints { match accumulated { Some(constraint) => NarrowingConstraint::intersection(db, base_ty) .merge_constraint_and(db, constraint) - .evaluate_constraint_type(db), + .evaluate_constraint_type(db, false), None => base_ty, } } @@ -943,7 +943,8 @@ impl ReachabilityConstraints { let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = narrow!(node.if_false, false_accumulated); - UnionType::from_two_elements(db, true_ty, false_ty) + // We won't do a union type redundancy check here, as it only needs to be performed once for the final result. + UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]) } }; diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index cec6cfe115436d..9df97e5ffa2928 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -12289,7 +12289,6 @@ pub(crate) fn walk_union<'db, V: visitor::TypeVisitor<'db> + ?Sized>( // The Salsa heap is tracked separately. impl get_size2::GetSize for UnionType<'_> {} -#[salsa::tracked] impl<'db> UnionType<'db> { /// Create a union from a list of elements /// (which may be eagerly simplified into a different variant of [`Type`] altogether). @@ -12306,15 +12305,18 @@ impl<'db> UnionType<'db> { .build() } - #[salsa::tracked( - cycle_initial=|_, id, _, _| Type::divergent(id), - cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _, _| { - result.cycle_normalized(db, *previous, cycle) - }, - heap_size=ruff_memory_usage::heap_size - )] - pub(crate) fn from_two_elements(db: &'db dyn Db, a: Type<'db>, b: Type<'db>) -> Type<'db> { - UnionBuilder::new(db).add(a).add(b).build() + pub(crate) fn from_elements_no_redundancy_check(db: &'db dyn Db, elements: I) -> Type<'db> + where + I: IntoIterator, + T: Into>, + { + elements + .into_iter() + .fold( + UnionBuilder::new(db).check_redundancy(false), + |builder, element| builder.add(element.into()), + ) + .build() } /// Create a union from a list of elements without unpacking type aliases. diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index cab00e118c971f..8c11873d98843f 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -341,11 +341,13 @@ const MAX_NON_RECURSIVE_UNION_LITERALS: usize = 256; /// if reachability analysis etc. fails when analysing these enums. const MAX_NON_RECURSIVE_UNION_ENUM_LITERALS: usize = 8192; +#[allow(clippy::struct_excessive_bools)] pub(crate) struct UnionBuilder<'db> { elements: Vec>, db: &'db dyn Db, unpack_aliases: bool, order_elements: bool, + check_redundancy: bool, /// This is enabled when joining types in a `cycle_recovery` function. /// Since a cycle cannot be created within a `cycle_recovery` function, /// execution of `is_redundant_with` is skipped. @@ -360,6 +362,7 @@ impl<'db> UnionBuilder<'db> { elements: vec![], unpack_aliases: true, order_elements: false, + check_redundancy: true, cycle_recovery: false, recursively_defined: RecursivelyDefined::No, } @@ -375,9 +378,15 @@ impl<'db> UnionBuilder<'db> { self } + pub(crate) fn check_redundancy(mut self, val: bool) -> Self { + self.check_redundancy = val; + self + } + pub(crate) fn cycle_recovery(mut self, val: bool) -> Self { self.cycle_recovery = val; if self.cycle_recovery { + self.check_redundancy = false; self.unpack_aliases = false; } self @@ -742,7 +751,7 @@ impl<'db> UnionBuilder<'db> { // If an alias gets here, it means we aren't unpacking aliases, and we also // shouldn't try to simplify aliases out of the union, because that will require // unpacking them. - let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && !self.cycle_recovery; + let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && self.check_redundancy; let mut ty_negated: Option = None; let mut to_remove = SmallVec::<[usize; 2]>::new(); diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 6c1f4d559c0ef4..3a14b04364b6cd 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -573,8 +573,12 @@ impl<'db> NarrowingConstraintBuilder<'db> { /// Evaluate the type this effectively constrains to /// /// Forgets whether each constraint originated from a `replacement` disjunct or not - pub(crate) fn evaluate_constraint_type(&self, db: &'db dyn Db) -> Type<'db> { - let mut union = UnionBuilder::new(db); + pub(crate) fn evaluate_constraint_type( + &self, + db: &'db dyn Db, + check_redundancy: bool, + ) -> Type<'db> { + let mut union = UnionBuilder::new(db).check_redundancy(check_redundancy); for conjunctions in self .replacement_disjuncts .iter() @@ -610,8 +614,13 @@ impl<'db> NarrowingConstraint<'db> { .finish(db) } - pub(crate) fn evaluate_constraint_type(self, db: &'db dyn Db) -> Type<'db> { - self.inner(db).evaluate_constraint_type(db) + pub(crate) fn evaluate_constraint_type( + self, + db: &'db dyn Db, + check_redundancy: bool, + ) -> Type<'db> { + self.inner(db) + .evaluate_constraint_type(db, check_redundancy) } } From 50c976cee8d4c7016704a89714faab910abe7d9b Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Thu, 19 Feb 2026 15:24:25 +0900 Subject: [PATCH 54/69] cache two types intersection as a tracked function (take 2) --- crates/ty_python_semantic/src/types.rs | 24 ++++++++++++++----- .../ty_python_semantic/src/types/builder.rs | 4 ++-- .../src/types/constraints.rs | 2 +- .../ty_python_semantic/src/types/generics.rs | 2 +- .../src/types/infer/builder.rs | 6 +++-- crates/ty_python_semantic/src/types/narrow.rs | 4 +++- crates/ty_python_semantic/src/types/tuple.rs | 6 ++--- 7 files changed, 32 insertions(+), 16 deletions(-) diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 9df97e5ffa2928..e1b2b8474df6cb 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -4399,7 +4399,7 @@ impl<'db> Type<'db> { .with_annotated_type(typevar_meta)]; // Intersect with `Any` for the return type to reflect the fact that the `dataclass()` // decorator adds methods to the class - let returns = IntersectionType::from_elements(db, [typevar_meta, Type::any()]); + let returns = IntersectionType::from_two_elements(db, typevar_meta, Type::any()); let signature = Signature::new_generic(Some(context), Parameters::new(db, parameters), returns); Binding::single(self, signature).into() @@ -6023,12 +6023,10 @@ impl<'db> Type<'db> { // but it appears to be what users often expect, and it improves compatibility with // other type checkers such as mypy. // See conversation in https://github.com/astral-sh/ruff/pull/19915. - SpecialFormType::NamedTuple => Ok(IntersectionType::from_elements( + SpecialFormType::NamedTuple => Ok(IntersectionType::from_two_elements( db, - [ - Type::homogeneous_tuple(db, Type::object()), - KnownClass::NamedTupleLike.to_instance(db), - ], + Type::homogeneous_tuple(db, Type::object()), + KnownClass::NamedTupleLike.to_instance(db), )), SpecialFormType::TypingSelf => { let index = semantic_index(db, scope_id.file(db)); @@ -12944,6 +12942,7 @@ pub(super) fn walk_intersection_type<'db, V: visitor::TypeVisitor<'db> + ?Sized> } } +#[salsa::tracked] impl<'db> IntersectionType<'db> { pub(crate) fn from_elements(db: &'db dyn Db, elements: I) -> Type<'db> where @@ -12955,6 +12954,19 @@ impl<'db> IntersectionType<'db> { .build() } + #[salsa::tracked( + cycle_initial=|_, id, _, _| Type::divergent(id), + cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _, _| { + result.cycle_normalized(db, *previous, cycle) + }, + heap_size=ruff_memory_usage::heap_size + )] + fn from_two_elements(db: &'db dyn Db, a: Type<'db>, b: Type<'db>) -> Type<'db> { + IntersectionBuilder::new(db) + .positive_elements([a, b]) + .build() + } + /// Return a new `IntersectionType` instance with the positive and negative types sorted /// according to a canonical ordering, and other normalizations applied to each element as applicable. /// diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 8c11873d98843f..967cb20228ef72 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -120,8 +120,8 @@ fn merge_truthiness_guarded_pair<'db>( } let candidate = UnionType::from_elements(db, [left_core, right_core]); - let left_reconstructed = IntersectionType::from_elements(db, [candidate, left_guard]); - let right_reconstructed = IntersectionType::from_elements(db, [candidate, right_guard]); + let left_reconstructed = IntersectionType::from_two_elements(db, candidate, left_guard); + let right_reconstructed = IntersectionType::from_two_elements(db, candidate, right_guard); if left_reconstructed == left && right_reconstructed == right { Some(candidate) } else { diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index 97f570a50e3cac..162040260d10f1 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -788,7 +788,7 @@ impl<'db> ConstrainedTypeVar<'db> { // (s₁ ≤ α ≤ t₁) ∧ (s₂ ≤ α ≤ t₂) = (s₁ ∪ s₂) ≤ α ≤ (t₁ ∩ t₂)) let lower = UnionType::from_elements(db, [self.lower(db), other.lower(db)]); - let upper = IntersectionType::from_elements(db, [self_upper, other_upper]); + let upper = IntersectionType::from_two_elements(db, self_upper, other_upper); // If `lower ≰ upper`, then the intersection is empty, since there is no type that is both // greater than `lower`, and less than `upper`. diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 62c9b69c1d2dd0..ff98cbeae9acc0 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -2158,7 +2158,7 @@ impl<'db> SpecializationBuilder<'db> { // check here. self.add_type_mapping( bound_typevar, - IntersectionType::from_elements(self.db, [bound, ty]), + IntersectionType::from_two_elements(self.db, bound, ty), polarity, f, ); diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 36ff0f03012e30..3fc23bb414d5e7 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -10815,7 +10815,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // different overloads provide different type context; unioning may be more // correct in those cases. *argument_type = argument_type - .map(|current| IntersectionType::from_elements(db, [inferred_ty, current])) + .map(|current| { + IntersectionType::from_two_elements(db, inferred_ty, current) + }) .or(Some(inferred_ty)); } @@ -11029,7 +11031,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .annotation .is_none_or(|tcx| ty.is_assignable_to(db, tcx)) { - *current = IntersectionType::from_elements(db, [*current, ty]); + *current = IntersectionType::from_two_elements(db, *current, ty); } }) .or_insert(ty); diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 3a14b04364b6cd..7d9b525f461d3f 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -430,6 +430,8 @@ impl<'db> Conjunctions<'db> { fn evaluate_constraint_type(&self, db: &'db dyn Db) -> Type<'db> { if self.conjuncts.len() == 1 { return self.conjuncts[0]; + } else if self.conjuncts.len() == 2 { + return IntersectionType::from_two_elements(db, self.conjuncts[0], self.conjuncts[1]); } let mut intersection = IntersectionBuilder::new(db); @@ -1622,7 +1624,7 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { .or_insert(constraint); // Use the narrowed type for subsequent comparisons in a chain. - last_rhs_ty = Some(IntersectionType::from_elements(self.db, [rhs_ty, ty])); + last_rhs_ty = Some(IntersectionType::from_two_elements(self.db, rhs_ty, ty)); } else { last_rhs_ty = Some(rhs_ty); } diff --git a/crates/ty_python_semantic/src/types/tuple.rs b/crates/ty_python_semantic/src/types/tuple.rs index 1ed94269d1d4f1..a65118657dbdb4 100644 --- a/crates/ty_python_semantic/src/types/tuple.rs +++ b/crates/ty_python_semantic/src/types/tuple.rs @@ -2131,11 +2131,11 @@ impl<'db> TupleSpecBuilder<'db> { && suffix.len() == var.suffix_elements().len() { for (existing, new) in prefix.iter_mut().zip(var.prefix_elements()) { - *existing = IntersectionType::from_elements(db, [*existing, *new]); + *existing = IntersectionType::from_two_elements(db, *existing, *new); } - *variable = IntersectionType::from_elements(db, [*variable, var.variable()]); + *variable = IntersectionType::from_two_elements(db, *variable, var.variable()); for (existing, new) in suffix.iter_mut().zip(var.suffix_elements()) { - *existing = IntersectionType::from_elements(db, [*existing, *new]); + *existing = IntersectionType::from_two_elements(db, *existing, *new); } return Some(self); } From adb4472201b1dc843e82528096c04e876ae5515d Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Thu, 19 Feb 2026 16:24:12 +0900 Subject: [PATCH 55/69] use `IntersectionType::from_two_elements` more --- crates/ty_python_semantic/src/types/narrow.rs | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 7d9b525f461d3f..39ff43799b78dd 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -428,17 +428,18 @@ impl<'db> Conjunctions<'db> { } fn evaluate_constraint_type(&self, db: &'db dyn Db) -> Type<'db> { - if self.conjuncts.len() == 1 { - return self.conjuncts[0]; - } else if self.conjuncts.len() == 2 { - return IntersectionType::from_two_elements(db, self.conjuncts[0], self.conjuncts[1]); - } - - let mut intersection = IntersectionBuilder::new(db); - for conjunct in self.conjuncts.iter().copied() { - intersection = intersection.add_positive(conjunct); - } - intersection.build() + let mut iter = self.conjuncts.iter().copied(); + let Some(first) = iter.next() else { + return Type::Never; + }; + // Fold conjuncts pairwise using `IntersectionType::from_two_elements`. + // When TDD paths share a common prefix of constraints + // (e.g., match cases accumulating ~P1 & ~P2 & ... & ~Pn), + // intermediate results like `base_ty & ~P1` and `(base_ty & ~P1) & ~P2` + // are cached and reused across paths, avoiding redundant recomputation. + iter.fold(first, |result, conjunct| { + IntersectionType::from_two_elements(db, result, conjunct) + }) } } From 5c321a08454c22540b9c935aab8306e0031fbb71 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 20 Feb 2026 15:29:49 +0900 Subject: [PATCH 56/69] add fast path for intersection type redundancy check --- .../ty_python_semantic/src/types/relation.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/ty_python_semantic/src/types/relation.rs b/crates/ty_python_semantic/src/types/relation.rs index fe4f96ed88df83..6492ca2d57083a 100644 --- a/crates/ty_python_semantic/src/types/relation.rs +++ b/crates/ty_python_semantic/src/types/relation.rs @@ -313,6 +313,39 @@ impl<'db> Type<'db> { return true; } + // Fast path for intersection types: use set-based subset check instead of + // the full `has_relation_to` machinery. This is critical for narrowing where + // many intersection types with overlapping positive elements are produced. + if let (Type::Intersection(self_inter), Type::Intersection(other_inter)) = (self, other) { + let self_pos = self_inter.positive(db); + let other_pos = other_inter.positive(db); + let self_neg = self_inter.negative(db); + let other_neg = other_inter.negative(db); + let other_pos_subset = other_pos.iter().all(|p| self_pos.contains(p)); + let other_neg_subset = other_neg.iter().all(|n| self_neg.contains(n)); + + // Intersection(pos_self, neg_self) is redundant with Intersection(pos_other, neg_other) if: + // pos_other ⊆ pos_self AND neg_other ⊆ neg_self + // Conversely, if pos_other ⊄ pos_self (some positive of other is missing from self), + // then self is NOT redundant with other. + if other_pos_subset && other_neg_subset { + return true; + } + + // If all positive elements are `NominalInstance` types and some positive + // of `other` is not contained in `self`'s positives, we can assume + // non-redundancy without the full `has_relation_to` check. + // This is not strictly correct for classes with inheritance relationship, + // but such a false negative is safe: it only causes the union to retain + // an extra element without affecting correctness. + if !other_pos_subset + && self_pos.iter().all(|t| t.is_nominal_instance()) + && other_pos.iter().all(|t| t.is_nominal_instance()) + { + return false; + } + } + is_redundant_with_impl(db, self, other) } From 93639270fb8f2215af84f92aebda39ce243576e7 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 20 Feb 2026 15:52:59 +0900 Subject: [PATCH 57/69] avoid expensive intersection type checks --- .../ty_python_semantic/src/types/builder.rs | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 967cb20228ef72..9cbbf92aa6ca1a 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -812,19 +812,27 @@ impl<'db> UnionBuilder<'db> { continue; } - let negated = ty_negated.get_or_insert_with(|| ty.negate(self.db)); - if negated.is_subtype_of(self.db, element_type) { - // We add `ty` to the union. We just checked that `~ty` is a subtype of an - // existing `element`. This also means that `~ty | ty` is a subtype of - // `element | ty`, because both elements in the first union are subtypes of - // the corresponding elements in the second union. But `~ty | ty` is just - // `object`. Since `object` is a subtype of `element | ty`, we can only - // conclude that `element | ty` must be `object` (object has no other - // supertypes). This means we can simplify the whole union to just - // `object`, since all other potential elements would also be subtypes of - // `object`. - self.collapse_to_object(); - return; + // Skip the negate/subtype check for intersection-to-intersection pairs. + // For intersections, ~(A & B & ...) = ~A | ~B | ..., which is a broad union + // of complements. Such a union cannot be a subtype of another intersection + // of class types in practice, making this check always false but expensive. + if !(ty.is_nontrivial_intersection(self.db) + && element_type.is_nontrivial_intersection(self.db)) + { + let negated = ty_negated.get_or_insert_with(|| ty.negate(self.db)); + if negated.is_subtype_of(self.db, element_type) { + // We add `ty` to the union. We just checked that `~ty` is a subtype of an + // existing `element`. This also means that `~ty | ty` is a subtype of + // `element | ty`, because both elements in the first union are subtypes of + // the corresponding elements in the second union. But `~ty | ty` is just + // `object`. Since `object` is a subtype of `element | ty`, we can only + // conclude that `element | ty` must be `object` (object has no other + // supertypes). This means we can simplify the whole union to just + // `object`, since all other potential elements would also be subtypes of + // `object`. + self.collapse_to_object(); + return; + } } } } From 1c8120bb800f588665be18aa88587c0c00da0ff0 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 20 Feb 2026 16:34:20 +0900 Subject: [PATCH 58/69] add benchmark for large union type narrowing --- crates/ruff_benchmark/benches/ty.rs | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/ruff_benchmark/benches/ty.rs b/crates/ruff_benchmark/benches/ty.rs index e6990de32ef2a0..6f5a122cea19d7 100644 --- a/crates/ruff_benchmark/benches/ty.rs +++ b/crates/ruff_benchmark/benches/ty.rs @@ -658,6 +658,70 @@ class E(Enum): }); } +/// Benchmark for narrowing a large union type through multiple match statements. +/// +/// This is extracted from egglog-python's `pretty.py`, where a ~30-class union type +/// (`AllDecls`) is narrowed by exhaustive match statements. +/// +/// Sample code structure: +/// ```python +/// from __future__ import annotations +/// from dataclasses import dataclass +/// +/// @dataclass +/// class C0: +/// value: int +/// ... +/// +/// AllDecls = C0 | C1 | ... +/// +/// def process(decl: AllDecls) -> None: +/// match decl: +/// case C0(): pass +/// ... +/// case _: pass +/// ``` +fn benchmark_large_union_narrowing(criterion: &mut Criterion) { + const NUM_CLASSES: usize = 30; + const NUM_MATCH_BRANCHES: usize = 29; + + setup_rayon(); + + let mut code = + "from __future__ import annotations\nfrom dataclasses import dataclass\n\n".to_string(); + + for i in 0..NUM_CLASSES { + writeln!(&mut code, "@dataclass\nclass C{i}:\n value: int\n").ok(); + } + + code.push_str("AllDecls = "); + for i in 0..NUM_CLASSES { + if i > 0 { + code.push_str(" | "); + } + write!(&mut code, "C{i}").ok(); + } + code.push_str("\n\n"); + + code.push_str("def process(decl: AllDecls) -> None:\n match decl:\n"); + for i in 0..NUM_MATCH_BRANCHES { + writeln!(&mut code, " case C{i}():\n pass",).ok(); + } + code.push_str(" case _:\n pass\n\n"); + + criterion.bench_function("ty_micro[large_union_narrowing]", |b| { + b.iter_batched_ref( + || setup_micro_case(&code), + |case| { + let Case { db, .. } = case; + let result = db.check(); + assert_eq!(result.len(), 0); + }, + BatchSize::SmallInput, + ); + }); +} + struct ProjectBenchmark<'a> { project: InstalledProject<'a>, fs: MemoryFileSystem, @@ -820,6 +884,7 @@ criterion_group!( benchmark_many_enum_members, benchmark_many_enum_members_2, benchmark_very_large_tuple, + benchmark_large_union_narrowing, ); criterion_group!(project, anyio, attrs, hydra, datetype); criterion_main!(check_file, micro, project); From 50b98662690aa0de99104a0d58fc0f671dd795f2 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 20 Feb 2026 18:44:08 +0900 Subject: [PATCH 59/69] Update post_if_statement.md --- .../resources/mdtest/narrow/post_if_statement.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md index d897e7836220b0..be84d381e9223c 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/post_if_statement.md @@ -205,6 +205,8 @@ def _(x: int | None): This also works when the always-true condition is nested inside a narrowing branch: ```py +from typing import Literal + def _(x: int | None): if x is None: if 1 + 1 == 2: @@ -218,6 +220,16 @@ def _(x: int | None): return reveal_type(x) # revealed: int + +def always_true(val: object) -> Literal[True]: + return True + +def _(x: int | None): + if x is None: + if always_true(x): + return + + reveal_type(x) # revealed: int ``` ## Narrowing from `assert` should not affect reassigned variables From 64d2a86262dd11c6f7004fbd8d88fcad8c27a108 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 20 Feb 2026 18:51:28 +0900 Subject: [PATCH 60/69] remove unnecessary code --- .../semantic_index/reachability_constraints.rs | 5 ++--- crates/ty_python_semantic/src/types.rs | 14 -------------- crates/ty_python_semantic/src/types/builder.rs | 11 +---------- crates/ty_python_semantic/src/types/narrow.rs | 17 ++++------------- 4 files changed, 7 insertions(+), 40 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 1f0b805583d579..b58e35df7685e5 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -872,7 +872,7 @@ impl ReachabilityConstraints { match accumulated { Some(constraint) => NarrowingConstraint::intersection(db, base_ty) .merge_constraint_and(db, constraint) - .evaluate_constraint_type(db, false), + .evaluate_constraint_type(db), None => base_ty, } } @@ -962,8 +962,7 @@ impl ReachabilityConstraints { let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = narrow!(node.if_false, false_accumulated); - // We won't do a union type redundancy check here, as it only needs to be performed once for the final result. - UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]) + UnionType::from_elements(db, [true_ty, false_ty]) } }; diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index e7a71731059ec5..c096c03dc86f70 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -12307,20 +12307,6 @@ impl<'db> UnionType<'db> { .build() } - pub(crate) fn from_elements_no_redundancy_check(db: &'db dyn Db, elements: I) -> Type<'db> - where - I: IntoIterator, - T: Into>, - { - elements - .into_iter() - .fold( - UnionBuilder::new(db).check_redundancy(false), - |builder, element| builder.add(element.into()), - ) - .build() - } - /// Create a union from a list of elements without unpacking type aliases. pub(crate) fn from_elements_leave_aliases(db: &'db dyn Db, elements: I) -> Type<'db> where diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 9cbbf92aa6ca1a..097e5632ee2758 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -341,13 +341,11 @@ const MAX_NON_RECURSIVE_UNION_LITERALS: usize = 256; /// if reachability analysis etc. fails when analysing these enums. const MAX_NON_RECURSIVE_UNION_ENUM_LITERALS: usize = 8192; -#[allow(clippy::struct_excessive_bools)] pub(crate) struct UnionBuilder<'db> { elements: Vec>, db: &'db dyn Db, unpack_aliases: bool, order_elements: bool, - check_redundancy: bool, /// This is enabled when joining types in a `cycle_recovery` function. /// Since a cycle cannot be created within a `cycle_recovery` function, /// execution of `is_redundant_with` is skipped. @@ -362,7 +360,6 @@ impl<'db> UnionBuilder<'db> { elements: vec![], unpack_aliases: true, order_elements: false, - check_redundancy: true, cycle_recovery: false, recursively_defined: RecursivelyDefined::No, } @@ -378,15 +375,9 @@ impl<'db> UnionBuilder<'db> { self } - pub(crate) fn check_redundancy(mut self, val: bool) -> Self { - self.check_redundancy = val; - self - } - pub(crate) fn cycle_recovery(mut self, val: bool) -> Self { self.cycle_recovery = val; if self.cycle_recovery { - self.check_redundancy = false; self.unpack_aliases = false; } self @@ -751,7 +742,7 @@ impl<'db> UnionBuilder<'db> { // If an alias gets here, it means we aren't unpacking aliases, and we also // shouldn't try to simplify aliases out of the union, because that will require // unpacking them. - let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && self.check_redundancy; + let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && !self.cycle_recovery; let mut ty_negated: Option = None; let mut to_remove = SmallVec::<[usize; 2]>::new(); diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 39ff43799b78dd..7110a37f6bc9f2 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -576,12 +576,8 @@ impl<'db> NarrowingConstraintBuilder<'db> { /// Evaluate the type this effectively constrains to /// /// Forgets whether each constraint originated from a `replacement` disjunct or not - pub(crate) fn evaluate_constraint_type( - &self, - db: &'db dyn Db, - check_redundancy: bool, - ) -> Type<'db> { - let mut union = UnionBuilder::new(db).check_redundancy(check_redundancy); + pub(crate) fn evaluate_constraint_type(&self, db: &'db dyn Db) -> Type<'db> { + let mut union = UnionBuilder::new(db); for conjunctions in self .replacement_disjuncts .iter() @@ -617,13 +613,8 @@ impl<'db> NarrowingConstraint<'db> { .finish(db) } - pub(crate) fn evaluate_constraint_type( - self, - db: &'db dyn Db, - check_redundancy: bool, - ) -> Type<'db> { - self.inner(db) - .evaluate_constraint_type(db, check_redundancy) + pub(crate) fn evaluate_constraint_type(self, db: &'db dyn Db) -> Type<'db> { + self.inner(db).evaluate_constraint_type(db) } } From c913247c498622f1006aba1f7b98a95c243f33c3 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Fri, 20 Feb 2026 19:15:46 +0900 Subject: [PATCH 61/69] Revert "remove unnecessary code" This reverts commit 64d2a86262dd11c6f7004fbd8d88fcad8c27a108. --- .../semantic_index/reachability_constraints.rs | 5 +++-- crates/ty_python_semantic/src/types.rs | 14 ++++++++++++++ crates/ty_python_semantic/src/types/builder.rs | 11 ++++++++++- crates/ty_python_semantic/src/types/narrow.rs | 17 +++++++++++++---- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index b58e35df7685e5..1f0b805583d579 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -872,7 +872,7 @@ impl ReachabilityConstraints { match accumulated { Some(constraint) => NarrowingConstraint::intersection(db, base_ty) .merge_constraint_and(db, constraint) - .evaluate_constraint_type(db), + .evaluate_constraint_type(db, false), None => base_ty, } } @@ -962,7 +962,8 @@ impl ReachabilityConstraints { let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = narrow!(node.if_false, false_accumulated); - UnionType::from_elements(db, [true_ty, false_ty]) + // We won't do a union type redundancy check here, as it only needs to be performed once for the final result. + UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]) } }; diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index c096c03dc86f70..e7a71731059ec5 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -12307,6 +12307,20 @@ impl<'db> UnionType<'db> { .build() } + pub(crate) fn from_elements_no_redundancy_check(db: &'db dyn Db, elements: I) -> Type<'db> + where + I: IntoIterator, + T: Into>, + { + elements + .into_iter() + .fold( + UnionBuilder::new(db).check_redundancy(false), + |builder, element| builder.add(element.into()), + ) + .build() + } + /// Create a union from a list of elements without unpacking type aliases. pub(crate) fn from_elements_leave_aliases(db: &'db dyn Db, elements: I) -> Type<'db> where diff --git a/crates/ty_python_semantic/src/types/builder.rs b/crates/ty_python_semantic/src/types/builder.rs index 097e5632ee2758..9cbbf92aa6ca1a 100644 --- a/crates/ty_python_semantic/src/types/builder.rs +++ b/crates/ty_python_semantic/src/types/builder.rs @@ -341,11 +341,13 @@ const MAX_NON_RECURSIVE_UNION_LITERALS: usize = 256; /// if reachability analysis etc. fails when analysing these enums. const MAX_NON_RECURSIVE_UNION_ENUM_LITERALS: usize = 8192; +#[allow(clippy::struct_excessive_bools)] pub(crate) struct UnionBuilder<'db> { elements: Vec>, db: &'db dyn Db, unpack_aliases: bool, order_elements: bool, + check_redundancy: bool, /// This is enabled when joining types in a `cycle_recovery` function. /// Since a cycle cannot be created within a `cycle_recovery` function, /// execution of `is_redundant_with` is skipped. @@ -360,6 +362,7 @@ impl<'db> UnionBuilder<'db> { elements: vec![], unpack_aliases: true, order_elements: false, + check_redundancy: true, cycle_recovery: false, recursively_defined: RecursivelyDefined::No, } @@ -375,9 +378,15 @@ impl<'db> UnionBuilder<'db> { self } + pub(crate) fn check_redundancy(mut self, val: bool) -> Self { + self.check_redundancy = val; + self + } + pub(crate) fn cycle_recovery(mut self, val: bool) -> Self { self.cycle_recovery = val; if self.cycle_recovery { + self.check_redundancy = false; self.unpack_aliases = false; } self @@ -742,7 +751,7 @@ impl<'db> UnionBuilder<'db> { // If an alias gets here, it means we aren't unpacking aliases, and we also // shouldn't try to simplify aliases out of the union, because that will require // unpacking them. - let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && !self.cycle_recovery; + let should_simplify_full = !matches!(ty, Type::TypeAlias(_)) && self.check_redundancy; let mut ty_negated: Option = None; let mut to_remove = SmallVec::<[usize; 2]>::new(); diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 7110a37f6bc9f2..39ff43799b78dd 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -576,8 +576,12 @@ impl<'db> NarrowingConstraintBuilder<'db> { /// Evaluate the type this effectively constrains to /// /// Forgets whether each constraint originated from a `replacement` disjunct or not - pub(crate) fn evaluate_constraint_type(&self, db: &'db dyn Db) -> Type<'db> { - let mut union = UnionBuilder::new(db); + pub(crate) fn evaluate_constraint_type( + &self, + db: &'db dyn Db, + check_redundancy: bool, + ) -> Type<'db> { + let mut union = UnionBuilder::new(db).check_redundancy(check_redundancy); for conjunctions in self .replacement_disjuncts .iter() @@ -613,8 +617,13 @@ impl<'db> NarrowingConstraint<'db> { .finish(db) } - pub(crate) fn evaluate_constraint_type(self, db: &'db dyn Db) -> Type<'db> { - self.inner(db).evaluate_constraint_type(db) + pub(crate) fn evaluate_constraint_type( + self, + db: &'db dyn Db, + check_redundancy: bool, + ) -> Type<'db> { + self.inner(db) + .evaluate_constraint_type(db, check_redundancy) } } From 003005686fc7cb9d49b3b9e9aec04ea0d23a1fd9 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 21 Feb 2026 00:08:57 +0900 Subject: [PATCH 62/69] add `MAX_NARROWING_GATING_MERGES` --- .../src/semantic_index/use_def/place_state.rs | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index a3ff20a3c15d09..fac847f1d6f96d 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -263,8 +263,19 @@ pub(crate) struct LiveBinding { pub(crate) binding: ScopedDefinitionId, pub(crate) narrowing_constraint: ScopedNarrowingConstraint, pub(crate) reachability_constraint: ScopedReachabilityConstraintId, + /// Number of reachability-gated narrowing merges this binding has been through. + /// When this exceeds [`MAX_NARROWING_GATING_MERGES`], we fall back to simple OR + /// to avoid mixing too many reachability atoms into the narrowing TDD. + narrowing_depth: u8, } +/// Maximum number of reachability-gated narrowing merges per binding before falling +/// back to simple OR. In functions with many if-branches, each merge can inject +/// reachability atoms into the narrowing TDD, causing exponential blowup during +/// evaluation. This limit ensures graceful degradation for deeply merged bindings +/// while preserving full precision for simple code. +const MAX_NARROWING_GATING_MERGES: u8 = 12; + pub(super) type LiveBindingsIterator<'a> = std::slice::Iter<'a, LiveBinding>; impl Bindings { @@ -273,6 +284,7 @@ impl Bindings { binding: ScopedDefinitionId::UNBOUND, narrowing_constraint: ScopedNarrowingConstraint::ALWAYS_TRUE, reachability_constraint, + narrowing_depth: 0, }; Self { unbound_narrowing_constraint: None, @@ -305,6 +317,7 @@ impl Bindings { binding, narrowing_constraint: ScopedNarrowingConstraint::ALWAYS_TRUE, reachability_constraint, + narrowing_depth: 0, }); self.latest_place_version } @@ -368,12 +381,18 @@ impl Bindings { let reachability_constraint = reachability_constraints .add_or_constraint(a.reachability_constraint, b.reachability_constraint); - let narrowing_constraint = if a.narrowing_constraint + let max_depth = a.narrowing_depth.max(b.narrowing_depth); + let (narrowing_constraint, new_depth) = if a.narrowing_constraint == ScopedNarrowingConstraint::ALWAYS_TRUE && b.narrowing_constraint == ScopedNarrowingConstraint::ALWAYS_TRUE { // short-circuit: if both sides are ALWAYS_TRUE, the result is ALWAYS_TRUE without needing to create a new TDD node. - ScopedNarrowingConstraint::ALWAYS_TRUE + (ScopedNarrowingConstraint::ALWAYS_TRUE, max_depth) + } else if max_depth > MAX_NARROWING_GATING_MERGES { + // Too many gated merges for this binding: fall back to simple OR to avoid TDD bloat from reachability atoms. + let narrowing = reachability_constraints + .add_or_constraint(a.narrowing_constraint, b.narrowing_constraint); + (narrowing, max_depth) } else { // A branch contributes narrowing only when it is reachable. // Without this gating, `OR(a_narrowing, b_narrowing)` allows an unreachable @@ -383,14 +402,16 @@ impl Bindings { .add_and_constraint(a.narrowing_constraint, a.reachability_constraint); let b_narrowing_gated = reachability_constraints .add_and_constraint(b.narrowing_constraint, b.reachability_constraint); - reachability_constraints - .add_or_constraint(a_narrowing_gated, b_narrowing_gated) + let narrowing = reachability_constraints + .add_or_constraint(a_narrowing_gated, b_narrowing_gated); + (narrowing, max_depth + 1) }; self.live_bindings.push(LiveBinding { binding: a.binding, narrowing_constraint, reachability_constraint, + narrowing_depth: new_depth, }); } From 9623224cfb21e87f96b6cf16ac0ba66d23d44a2a Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 21 Feb 2026 00:34:55 +0900 Subject: [PATCH 63/69] Revert "add `MAX_NARROWING_GATING_MERGES`" This reverts commit 003005686fc7cb9d49b3b9e9aec04ea0d23a1fd9. --- .../src/semantic_index/use_def/place_state.rs | 29 +++---------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index fac847f1d6f96d..a3ff20a3c15d09 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -263,19 +263,8 @@ pub(crate) struct LiveBinding { pub(crate) binding: ScopedDefinitionId, pub(crate) narrowing_constraint: ScopedNarrowingConstraint, pub(crate) reachability_constraint: ScopedReachabilityConstraintId, - /// Number of reachability-gated narrowing merges this binding has been through. - /// When this exceeds [`MAX_NARROWING_GATING_MERGES`], we fall back to simple OR - /// to avoid mixing too many reachability atoms into the narrowing TDD. - narrowing_depth: u8, } -/// Maximum number of reachability-gated narrowing merges per binding before falling -/// back to simple OR. In functions with many if-branches, each merge can inject -/// reachability atoms into the narrowing TDD, causing exponential blowup during -/// evaluation. This limit ensures graceful degradation for deeply merged bindings -/// while preserving full precision for simple code. -const MAX_NARROWING_GATING_MERGES: u8 = 12; - pub(super) type LiveBindingsIterator<'a> = std::slice::Iter<'a, LiveBinding>; impl Bindings { @@ -284,7 +273,6 @@ impl Bindings { binding: ScopedDefinitionId::UNBOUND, narrowing_constraint: ScopedNarrowingConstraint::ALWAYS_TRUE, reachability_constraint, - narrowing_depth: 0, }; Self { unbound_narrowing_constraint: None, @@ -317,7 +305,6 @@ impl Bindings { binding, narrowing_constraint: ScopedNarrowingConstraint::ALWAYS_TRUE, reachability_constraint, - narrowing_depth: 0, }); self.latest_place_version } @@ -381,18 +368,12 @@ impl Bindings { let reachability_constraint = reachability_constraints .add_or_constraint(a.reachability_constraint, b.reachability_constraint); - let max_depth = a.narrowing_depth.max(b.narrowing_depth); - let (narrowing_constraint, new_depth) = if a.narrowing_constraint + let narrowing_constraint = if a.narrowing_constraint == ScopedNarrowingConstraint::ALWAYS_TRUE && b.narrowing_constraint == ScopedNarrowingConstraint::ALWAYS_TRUE { // short-circuit: if both sides are ALWAYS_TRUE, the result is ALWAYS_TRUE without needing to create a new TDD node. - (ScopedNarrowingConstraint::ALWAYS_TRUE, max_depth) - } else if max_depth > MAX_NARROWING_GATING_MERGES { - // Too many gated merges for this binding: fall back to simple OR to avoid TDD bloat from reachability atoms. - let narrowing = reachability_constraints - .add_or_constraint(a.narrowing_constraint, b.narrowing_constraint); - (narrowing, max_depth) + ScopedNarrowingConstraint::ALWAYS_TRUE } else { // A branch contributes narrowing only when it is reachable. // Without this gating, `OR(a_narrowing, b_narrowing)` allows an unreachable @@ -402,16 +383,14 @@ impl Bindings { .add_and_constraint(a.narrowing_constraint, a.reachability_constraint); let b_narrowing_gated = reachability_constraints .add_and_constraint(b.narrowing_constraint, b.reachability_constraint); - let narrowing = reachability_constraints - .add_or_constraint(a_narrowing_gated, b_narrowing_gated); - (narrowing, max_depth + 1) + reachability_constraints + .add_or_constraint(a_narrowing_gated, b_narrowing_gated) }; self.live_bindings.push(LiveBinding { binding: a.binding, narrowing_constraint, reachability_constraint, - narrowing_depth: new_depth, }); } From a82538c7ff9bbbda9b6d15bb4b64aeed3f885b7a Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 21 Feb 2026 00:43:29 +0900 Subject: [PATCH 64/69] more aggressive short circuit in `Bindings::merge` --- .../src/semantic_index/use_def/place_state.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs index a3ff20a3c15d09..e34ba4a5ce2350 100644 --- a/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs +++ b/crates/ty_python_semantic/src/semantic_index/use_def/place_state.rs @@ -368,12 +368,12 @@ impl Bindings { let reachability_constraint = reachability_constraints .add_or_constraint(a.reachability_constraint, b.reachability_constraint); - let narrowing_constraint = if a.narrowing_constraint - == ScopedNarrowingConstraint::ALWAYS_TRUE - && b.narrowing_constraint == ScopedNarrowingConstraint::ALWAYS_TRUE - { - // short-circuit: if both sides are ALWAYS_TRUE, the result is ALWAYS_TRUE without needing to create a new TDD node. - ScopedNarrowingConstraint::ALWAYS_TRUE + let narrowing_constraint = if a.narrowing_constraint == b.narrowing_constraint { + // short-circuit: if both sides have the same constraint, we can use that constraint without needing to create a new TDD node. + a.narrowing_constraint + } else if a.reachability_constraint == b.reachability_constraint { + reachability_constraints + .add_or_constraint(a.narrowing_constraint, b.narrowing_constraint) } else { // A branch contributes narrowing only when it is reachable. // Without this gating, `OR(a_narrowing, b_narrowing)` allows an unreachable From 52c41040b63b04314a9b66f4c5055a3afd23b3db Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 21 Feb 2026 02:48:35 +0900 Subject: [PATCH 65/69] optimize `narrow_by_constraint_inner` --- .../resources/mdtest/narrow/match.md | 2 +- .../src/semantic_index/reachability_constraints.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index d18d48b2b745cc..34468027be3eb5 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -254,7 +254,7 @@ def _(x: Literal["foo", b"bar"] | int): pass case b"bar" if reveal_type(x): # revealed: Literal[b"bar"] | int pass - case _ if reveal_type(x): # revealed: int | Literal["foo", b"bar"] + case _ if reveal_type(x): # revealed: Literal["foo", b"bar"] | int pass ``` diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 1f0b805583d579..65a3959f37ef68 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -958,10 +958,22 @@ impl ReachabilityConstraints { let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); let true_ty = narrow!(node.if_true, true_accumulated); + // Narrowing can only produce subtypes of `base_ty`, so + // if one branch already returns `base_ty`, skip the other. + if true_ty == base_ty { + memo.insert(key, base_ty); + return base_ty; + } + // False branch: predicate doesn't hold → accumulate negative narrowing let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = narrow!(node.if_false, false_accumulated); + if false_ty == base_ty { + memo.insert(key, base_ty); + return base_ty; + } + // We won't do a union type redundancy check here, as it only needs to be performed once for the final result. UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]) } From 736aeebf470641aac6f38058e46f6a95ed99d380 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 21 Feb 2026 03:52:44 +0900 Subject: [PATCH 66/69] experiment: revert "optimize `narrow_by_constraint_inner`" This reverts commit 52c41040b63b04314a9b66f4c5055a3afd23b3db. --- .../resources/mdtest/narrow/match.md | 2 +- .../src/semantic_index/reachability_constraints.rs | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 34468027be3eb5..d18d48b2b745cc 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -254,7 +254,7 @@ def _(x: Literal["foo", b"bar"] | int): pass case b"bar" if reveal_type(x): # revealed: Literal[b"bar"] | int pass - case _ if reveal_type(x): # revealed: Literal["foo", b"bar"] | int + case _ if reveal_type(x): # revealed: int | Literal["foo", b"bar"] pass ``` diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 26ee3a291e6acb..1ade7bf4990128 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -958,22 +958,10 @@ impl ReachabilityConstraints { let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); let true_ty = narrow!(node.if_true, true_accumulated); - // Narrowing can only produce subtypes of `base_ty`, so - // if one branch already returns `base_ty`, skip the other. - if true_ty == base_ty { - memo.insert(key, base_ty); - return base_ty; - } - // False branch: predicate doesn't hold → accumulate negative narrowing let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = narrow!(node.if_false, false_accumulated); - if false_ty == base_ty { - memo.insert(key, base_ty); - return base_ty; - } - // We won't do a union type redundancy check here, as it only needs to be performed once for the final result. UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]) } From 4f2ce843bdee096332d8c34a404d3404ab8261ee Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 21 Feb 2026 04:14:58 +0900 Subject: [PATCH 67/69] Revert "experiment: revert "optimize `narrow_by_constraint_inner`"" This reverts commit 736aeebf470641aac6f38058e46f6a95ed99d380. --- .../resources/mdtest/narrow/match.md | 2 +- .../src/semantic_index/reachability_constraints.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index d18d48b2b745cc..34468027be3eb5 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -254,7 +254,7 @@ def _(x: Literal["foo", b"bar"] | int): pass case b"bar" if reveal_type(x): # revealed: Literal[b"bar"] | int pass - case _ if reveal_type(x): # revealed: int | Literal["foo", b"bar"] + case _ if reveal_type(x): # revealed: Literal["foo", b"bar"] | int pass ``` diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 1ade7bf4990128..26ee3a291e6acb 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -958,10 +958,22 @@ impl ReachabilityConstraints { let true_accumulated = accumulate_constraint(db, accumulated, pos_constraint); let true_ty = narrow!(node.if_true, true_accumulated); + // Narrowing can only produce subtypes of `base_ty`, so + // if one branch already returns `base_ty`, skip the other. + if true_ty == base_ty { + memo.insert(key, base_ty); + return base_ty; + } + // False branch: predicate doesn't hold → accumulate negative narrowing let false_accumulated = accumulate_constraint(db, accumulated, neg_constraint); let false_ty = narrow!(node.if_false, false_accumulated); + if false_ty == base_ty { + memo.insert(key, base_ty); + return base_ty; + } + // We won't do a union type redundancy check here, as it only needs to be performed once for the final result. UnionType::from_elements_no_redundancy_check(db, [true_ty, false_ty]) } From 89eb8f93a92aa956c5472bc4a0c5ed7f577ac7fa Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 21 Feb 2026 12:42:22 +0900 Subject: [PATCH 68/69] tracked `evaluate_constraint_type` --- .../reachability_constraints.rs | 2 +- crates/ty_python_semantic/src/types/narrow.rs | 25 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 26ee3a291e6acb..860644b5e7914e 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -872,7 +872,7 @@ impl ReachabilityConstraints { match accumulated { Some(constraint) => NarrowingConstraint::intersection(db, base_ty) .merge_constraint_and(db, constraint) - .evaluate_constraint_type(db, false), + .evaluate_constraint_type(db), None => base_ty, } } diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 39ff43799b78dd..53fe378f907073 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -576,12 +576,8 @@ impl<'db> NarrowingConstraintBuilder<'db> { /// Evaluate the type this effectively constrains to /// /// Forgets whether each constraint originated from a `replacement` disjunct or not - pub(crate) fn evaluate_constraint_type( - &self, - db: &'db dyn Db, - check_redundancy: bool, - ) -> Type<'db> { - let mut union = UnionBuilder::new(db).check_redundancy(check_redundancy); + pub(crate) fn evaluate_constraint_type(&self, db: &'db dyn Db) -> Type<'db> { + let mut union = UnionBuilder::new(db); for conjunctions in self .replacement_disjuncts .iter() @@ -593,6 +589,7 @@ impl<'db> NarrowingConstraintBuilder<'db> { } } +#[salsa::tracked] impl<'db> NarrowingConstraint<'db> { pub(crate) fn intersection(db: &'db dyn Db, constraint: Type<'db>) -> Self { NarrowingConstraintBuilder::intersection(constraint).finish(db) @@ -617,13 +614,15 @@ impl<'db> NarrowingConstraint<'db> { .finish(db) } - pub(crate) fn evaluate_constraint_type( - self, - db: &'db dyn Db, - check_redundancy: bool, - ) -> Type<'db> { - self.inner(db) - .evaluate_constraint_type(db, check_redundancy) + #[salsa::tracked( + cycle_initial=|_, id, _| Type::divergent(id), + cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _| { + result.cycle_normalized(db, *previous, cycle) + }, + heap_size=ruff_memory_usage::heap_size + )] + pub(crate) fn evaluate_constraint_type(self, db: &'db dyn Db) -> Type<'db> { + self.inner(db).evaluate_constraint_type(db) } } From f251ccb330dec821b949218ab51ddc82d5ac4456 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama Date: Sat, 21 Feb 2026 13:06:23 +0900 Subject: [PATCH 69/69] Revert "tracked `evaluate_constraint_type`" This reverts commit 89eb8f93a92aa956c5472bc4a0c5ed7f577ac7fa. --- .../reachability_constraints.rs | 2 +- crates/ty_python_semantic/src/types/narrow.rs | 25 ++++++++++--------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs index 860644b5e7914e..26ee3a291e6acb 100644 --- a/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs +++ b/crates/ty_python_semantic/src/semantic_index/reachability_constraints.rs @@ -872,7 +872,7 @@ impl ReachabilityConstraints { match accumulated { Some(constraint) => NarrowingConstraint::intersection(db, base_ty) .merge_constraint_and(db, constraint) - .evaluate_constraint_type(db), + .evaluate_constraint_type(db, false), None => base_ty, } } diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 53fe378f907073..39ff43799b78dd 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -576,8 +576,12 @@ impl<'db> NarrowingConstraintBuilder<'db> { /// Evaluate the type this effectively constrains to /// /// Forgets whether each constraint originated from a `replacement` disjunct or not - pub(crate) fn evaluate_constraint_type(&self, db: &'db dyn Db) -> Type<'db> { - let mut union = UnionBuilder::new(db); + pub(crate) fn evaluate_constraint_type( + &self, + db: &'db dyn Db, + check_redundancy: bool, + ) -> Type<'db> { + let mut union = UnionBuilder::new(db).check_redundancy(check_redundancy); for conjunctions in self .replacement_disjuncts .iter() @@ -589,7 +593,6 @@ impl<'db> NarrowingConstraintBuilder<'db> { } } -#[salsa::tracked] impl<'db> NarrowingConstraint<'db> { pub(crate) fn intersection(db: &'db dyn Db, constraint: Type<'db>) -> Self { NarrowingConstraintBuilder::intersection(constraint).finish(db) @@ -614,15 +617,13 @@ impl<'db> NarrowingConstraint<'db> { .finish(db) } - #[salsa::tracked( - cycle_initial=|_, id, _| Type::divergent(id), - cycle_fn=|db, cycle, previous: &Type<'db>, result: Type<'db>, _| { - result.cycle_normalized(db, *previous, cycle) - }, - heap_size=ruff_memory_usage::heap_size - )] - pub(crate) fn evaluate_constraint_type(self, db: &'db dyn Db) -> Type<'db> { - self.inner(db).evaluate_constraint_type(db) + pub(crate) fn evaluate_constraint_type( + self, + db: &'db dyn Db, + check_redundancy: bool, + ) -> Type<'db> { + self.inner(db) + .evaluate_constraint_type(db, check_redundancy) } }