From 8770b95509b7a5a3b23061d739e6260303613f55 Mon Sep 17 00:00:00 2001 From: Shunsuke Shibayama <45118249+mtshiba@users.noreply.github.com> Date: Thu, 11 Sep 2025 00:32:26 +0900 Subject: [PATCH] [ty] introduce `DivergentType` (#20312) ## Summary From #17371 In #17371, `DivergentType` was introduced to prevent type inference for recursive functions from diverging and causing panics. This turned out to be useful for other divergent type inferences (https://github.com/astral-sh/ruff/pull/17371#discussion_r2329337965), so I extracted the introduction part of `DivergentType` into this PR so that we can use it without waiting for the merge of #17371. Note that this PR only introduces `DivergentType` and does not actually address divergent type inference yet. Please refer to https://github.com/astral-sh/ruff/pull/17371/files#diff-20b910c6e20faa962bb1642e111db1cbad8e66ace089bdd966ac9d7f9fa99ff2R542-R622 etc. when implementing handling of divergence suppression using this type. ## Test Plan --------- Co-authored-by: Carl Meyer --- .../src/semantic_index/scope.rs | 1 + crates/ty_python_semantic/src/types.rs | 100 ++++++++++++++++-- .../src/types/class_base.rs | 3 +- crates/ty_python_semantic/src/types/infer.rs | 3 + .../src/types/subclass_of.rs | 8 +- .../src/types/type_ordering.rs | 6 ++ 6 files changed, 106 insertions(+), 15 deletions(-) diff --git a/crates/ty_python_semantic/src/semantic_index/scope.rs b/crates/ty_python_semantic/src/semantic_index/scope.rs index deac196831..dc24988207 100644 --- a/crates/ty_python_semantic/src/semantic_index/scope.rs +++ b/crates/ty_python_semantic/src/semantic_index/scope.rs @@ -15,6 +15,7 @@ use crate::{ /// A cross-module identifier of a scope that can be used as a salsa query parameter. #[salsa::tracked(debug, heap_size=ruff_memory_usage::heap_size)] +#[derive(PartialOrd, Ord)] pub struct ScopeId<'db> { pub file: File, diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index d47f097440..42ad5d2fd8 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -65,6 +65,7 @@ use crate::types::signatures::{Parameter, ParameterForm, Parameters, walk_signat use crate::types::tuple::TupleSpec; pub(crate) use crate::types::typed_dict::{TypedDictParams, TypedDictType, walk_typed_dict_type}; use crate::types::variance::{TypeVarVariance, VarianceInferable}; +use crate::types::visitor::any_over_type; use crate::unpack::EvaluationMode; pub use crate::util::diagnostics::add_inferred_python_version_hint_to_diagnostic; use crate::{Db, FxOrderSet, Module, Program}; @@ -602,7 +603,7 @@ impl From for DataclassParams { #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] pub enum Type<'db> { /// The dynamic type: a statically unknown set of values - Dynamic(DynamicType), + Dynamic(DynamicType<'db>), /// The empty set of values Never, /// A specific function object @@ -885,14 +886,14 @@ impl<'db> Type<'db> { } } - pub(crate) const fn into_dynamic(self) -> Option { + pub(crate) const fn into_dynamic(self) -> Option> { match self { Type::Dynamic(dynamic_type) => Some(dynamic_type), _ => None, } } - pub(crate) const fn expect_dynamic(self) -> DynamicType { + pub(crate) const fn expect_dynamic(self) -> DynamicType<'db> { self.into_dynamic() .expect("Expected a Type::Dynamic variant") } @@ -1857,6 +1858,10 @@ impl<'db> Type<'db> { } match (self, other) { + // The `Divergent` type is a special type that is not equivalent to other kinds of dynamic types, + // which prevents `Divergent` from being eliminated during union reduction. + (Type::Dynamic(_), Type::Dynamic(DynamicType::Divergent(_))) + | (Type::Dynamic(DynamicType::Divergent(_)), Type::Dynamic(_)) => C::unsatisfiable(db), (Type::Dynamic(_), Type::Dynamic(_)) => C::always_satisfiable(db), (Type::SubclassOf(first), Type::SubclassOf(second)) => { @@ -6566,6 +6571,14 @@ impl<'db> Type<'db> { _ => None, } } + + #[allow(unused)] + pub(super) fn has_divergent_type(self, db: &'db dyn Db, div: Type<'db>) -> bool { + any_over_type(db, self, &|ty| match ty { + Type::Dynamic(DynamicType::Divergent(_)) => ty == div, + _ => false, + }) + } } impl<'db> From<&Type<'db>> for Type<'db> { @@ -6960,8 +6973,19 @@ impl<'db> KnownInstanceType<'db> { } } -#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, get_size2::GetSize)] -pub enum DynamicType { +/// A type that is determined to be divergent during recursive type inference. +/// This type must never be eliminated by dynamic type reduction +/// (e.g. `Divergent` is assignable to `@Todo`, but `@Todo | Divergent` must not be reducted to `@Todo`). +/// Otherwise, type inference cannot converge properly. +/// For detailed properties of this type, see the unit test at the end of the file. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +pub struct DivergentType<'db> { + /// The scope where this divergence was detected. + scope: ScopeId<'db>, +} + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)] +pub enum DynamicType<'db> { /// An explicitly annotated `typing.Any` Any, /// An unannotated value, or a dynamic type resulting from an error @@ -6984,16 +7008,21 @@ pub enum DynamicType { TodoTypeAlias, /// A special Todo-variant for `Unpack[Ts]`, so that we can treat it specially in `Generic[Unpack[Ts]]` TodoUnpack, + /// A type that is determined to be divergent during type inference for a recursive function. + Divergent(DivergentType<'db>), } -impl DynamicType { - #[expect(clippy::unused_self)] +impl DynamicType<'_> { fn normalized(self) -> Self { - Self::Any + if matches!(self, Self::Divergent(_)) { + self + } else { + Self::Any + } } } -impl std::fmt::Display for DynamicType { +impl std::fmt::Display for DynamicType<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DynamicType::Any => f.write_str("Any"), @@ -7022,6 +7051,7 @@ impl std::fmt::Display for DynamicType { f.write_str("@Todo") } } + DynamicType::Divergent(_) => f.write_str("Divergent"), } } } @@ -10290,7 +10320,7 @@ impl BoundSuperError<'_> { #[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, get_size2::GetSize)] pub enum SuperOwnerKind<'db> { - Dynamic(DynamicType), + Dynamic(DynamicType<'db>), Class(ClassType<'db>), Instance(NominalInstanceType<'db>), } @@ -10656,6 +10686,7 @@ pub(crate) mod tests { use super::*; use crate::db::tests::{TestDbBuilder, setup_db}; use crate::place::{global_symbol, typing_extensions_symbol, typing_symbol}; + use crate::semantic_index::FileScopeId; use ruff_db::files::system_path_to_file; use ruff_db::parsed::parsed_module; use ruff_db::system::DbWithWritableSystem as _; @@ -10801,4 +10832,53 @@ pub(crate) mod tests { .is_todo() ); } + + #[test] + fn divergent_type() { + let mut db = setup_db(); + + db.write_dedented("src/foo.py", "").unwrap(); + let file = system_path_to_file(&db, "src/foo.py").unwrap(); + let file_scope_id = FileScopeId::global(); + let scope = file_scope_id.to_scope_id(&db, file); + + let div = Type::Dynamic(DynamicType::Divergent(DivergentType { scope })); + + // The `Divergent` type must not be eliminated in union with other dynamic types, + // as this would prevent detection of divergent type inference using `Divergent`. + let union = UnionType::from_elements(&db, [Type::unknown(), div]); + assert_eq!(union.display(&db).to_string(), "Unknown | Divergent"); + + let union = UnionType::from_elements(&db, [div, Type::unknown()]); + assert_eq!(union.display(&db).to_string(), "Divergent | Unknown"); + + let union = UnionType::from_elements(&db, [div, Type::unknown(), todo_type!("1")]); + assert_eq!(union.display(&db).to_string(), "Divergent | Unknown"); + + assert!(div.is_equivalent_to(&db, div)); + assert!(!div.is_equivalent_to(&db, Type::unknown())); + assert!(!Type::unknown().is_equivalent_to(&db, div)); + + // The `object` type has a good convergence property, that is, its union with all other types is `object`. + // (e.g. `object | tuple[Divergent] == object`, `object | tuple[object] == object`) + // So we can safely eliminate `Divergent`. + let union = UnionType::from_elements(&db, [div, KnownClass::Object.to_instance(&db)]); + assert_eq!(union.display(&db).to_string(), "object"); + + let union = UnionType::from_elements(&db, [KnownClass::Object.to_instance(&db), div]); + assert_eq!(union.display(&db).to_string(), "object"); + + // The same can be said about intersections for the `Never` type. + let intersection = IntersectionBuilder::new(&db) + .add_positive(Type::Never) + .add_positive(div) + .build(); + assert_eq!(intersection.display(&db).to_string(), "Never"); + + let intersection = IntersectionBuilder::new(&db) + .add_positive(div) + .add_positive(Type::Never) + .build(); + assert_eq!(intersection.display(&db).to_string(), "Never"); + } } diff --git a/crates/ty_python_semantic/src/types/class_base.rs b/crates/ty_python_semantic/src/types/class_base.rs index 1ca9b4e199..1bcf448218 100644 --- a/crates/ty_python_semantic/src/types/class_base.rs +++ b/crates/ty_python_semantic/src/types/class_base.rs @@ -18,7 +18,7 @@ use crate::types::{ /// automatically construct the default specialization for that class. #[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)] pub enum ClassBase<'db> { - Dynamic(DynamicType), + Dynamic(DynamicType<'db>), Class(ClassType<'db>), /// Although `Protocol` is not a class in typeshed's stubs, it is at runtime, /// and can appear in the MRO of a class. @@ -54,6 +54,7 @@ impl<'db> ClassBase<'db> { | DynamicType::TodoTypeAlias | DynamicType::TodoUnpack, ) => "@Todo", + ClassBase::Dynamic(DynamicType::Divergent(_)) => "Divergent", ClassBase::Protocol => "Protocol", ClassBase::Generic => "Generic", ClassBase::TypedDict => "TypedDict", diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index fba2a325f8..e7aca51cfb 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -7544,6 +7544,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // Non-todo Anys take precedence over Todos (as if we fix this `Todo` in the future, // the result would then become Any or Unknown, respectively). + (div @ Type::Dynamic(DynamicType::Divergent(_)), _, _) + | (_, div @ Type::Dynamic(DynamicType::Divergent(_)), _) => Some(div), + (any @ Type::Dynamic(DynamicType::Any), _, _) | (_, any @ Type::Dynamic(DynamicType::Any), _) => Some(any), diff --git a/crates/ty_python_semantic/src/types/subclass_of.rs b/crates/ty_python_semantic/src/types/subclass_of.rs index e27799210f..35ecbac7ee 100644 --- a/crates/ty_python_semantic/src/types/subclass_of.rs +++ b/crates/ty_python_semantic/src/types/subclass_of.rs @@ -221,7 +221,7 @@ impl<'db> VarianceInferable<'db> for SubclassOfType<'db> { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] pub(crate) enum SubclassOfInner<'db> { Class(ClassType<'db>), - Dynamic(DynamicType), + Dynamic(DynamicType<'db>), } impl<'db> SubclassOfInner<'db> { @@ -240,7 +240,7 @@ impl<'db> SubclassOfInner<'db> { } } - pub(crate) const fn into_dynamic(self) -> Option { + pub(crate) const fn into_dynamic(self) -> Option> { match self { Self::Class(_) => None, Self::Dynamic(dynamic) => Some(dynamic), @@ -271,8 +271,8 @@ impl<'db> From> for SubclassOfInner<'db> { } } -impl From for SubclassOfInner<'_> { - fn from(value: DynamicType) -> Self { +impl<'db> From> for SubclassOfInner<'db> { + fn from(value: DynamicType<'db>) -> Self { SubclassOfInner::Dynamic(value) } } diff --git a/crates/ty_python_semantic/src/types/type_ordering.rs b/crates/ty_python_semantic/src/types/type_ordering.rs index 70a8c55180..3a0f1cd251 100644 --- a/crates/ty_python_semantic/src/types/type_ordering.rs +++ b/crates/ty_python_semantic/src/types/type_ordering.rs @@ -275,6 +275,12 @@ fn dynamic_elements_ordering(left: DynamicType, right: DynamicType) -> Ordering (DynamicType::TodoTypeAlias, _) => Ordering::Less, (_, DynamicType::TodoTypeAlias) => Ordering::Greater, + + (DynamicType::Divergent(left), DynamicType::Divergent(right)) => { + left.scope.cmp(&right.scope) + } + (DynamicType::Divergent(_), _) => Ordering::Less, + (_, DynamicType::Divergent(_)) => Ordering::Greater, } }