[ty] Support 'dangling' type(...) constructors

This commit is contained in:
Charlie Marsh
2026-01-12 13:11:21 -05:00
parent 4abc5fe2f1
commit 3932ef7c46
10 changed files with 243 additions and 86 deletions

View File

@@ -1,8 +1,8 @@
use rustc_hash::FxHashMap;
use ruff_index::newtype_index;
use ruff_index::{IndexVec, newtype_index};
use ruff_python_ast as ast;
use ruff_python_ast::ExprRef;
use ruff_text_size::TextRange;
use rustc_hash::FxHashMap;
use crate::Db;
use crate::semantic_index::ast_ids::node_key::ExpressionNodeKey;
@@ -28,12 +28,27 @@ use crate::semantic_index::semantic_index;
pub(crate) struct AstIds {
/// Maps expressions which "use" a place (that is, [`ast::ExprName`], [`ast::ExprAttribute`] or [`ast::ExprSubscript`]) to a use id.
uses_map: FxHashMap<ExpressionNodeKey, ScopedUseId>,
/// Maps potential synthesized-type call expressions to a call id for stable identity.
tracked_calls_map: FxHashMap<ExpressionNodeKey, ScopedCallId>,
/// Stores the ranges of tracked calls, indexed by their [`ScopedCallId`].
/// Used for diagnostics (e.g., `header_range`).
tracked_call_ranges: IndexVec<ScopedCallId, TextRange>,
}
impl AstIds {
fn use_id(&self, key: impl Into<ExpressionNodeKey>) -> ScopedUseId {
self.uses_map[&key.into()]
}
/// Returns the call ID for a potential synthesized-type call, if it was tracked during semantic indexing.
pub(crate) fn try_call_id(&self, key: impl Into<ExpressionNodeKey>) -> Option<ScopedCallId> {
self.tracked_calls_map.get(&key.into()).copied()
}
/// Returns the range of a tracked call by its ID.
pub(crate) fn call_range(&self, id: ScopedCallId) -> TextRange {
self.tracked_call_ranges[id]
}
}
fn ast_ids<'db>(db: &'db dyn Db, scope: ScopeId) -> &'db AstIds {
@@ -45,6 +60,15 @@ fn ast_ids<'db>(db: &'db dyn Db, scope: ScopeId) -> &'db AstIds {
#[derive(get_size2::GetSize)]
pub struct ScopedUseId;
/// Uniquely identifies a potential synthesized-type call in a [`crate::semantic_index::FileScopeId`].
///
/// This is used to provide stable identity for inline calls that create synthesized types,
/// such as `type()`, `NamedTuple()`, `TypedDict()`, etc. The ID is assigned during semantic
/// indexing for calls that match known patterns for these synthesizers.
#[newtype_index]
#[derive(get_size2::GetSize)]
pub struct ScopedCallId;
pub trait HasScopedUseId {
/// Returns the ID that uniquely identifies the use in `scope`.
fn scoped_use_id(&self, db: &dyn Db, scope: ScopeId) -> ScopedUseId;
@@ -88,6 +112,8 @@ impl HasScopedUseId for ast::ExprRef<'_> {
#[derive(Debug, Default)]
pub(super) struct AstIdsBuilder {
uses_map: FxHashMap<ExpressionNodeKey, ScopedUseId>,
tracked_calls_map: FxHashMap<ExpressionNodeKey, ScopedCallId>,
tracked_call_ranges: IndexVec<ScopedCallId, TextRange>,
}
impl AstIdsBuilder {
@@ -100,11 +126,25 @@ impl AstIdsBuilder {
use_id
}
/// Records a potential synthesized-type call for stable identity tracking.
pub(super) fn record_call(
&mut self,
expr: impl Into<ExpressionNodeKey>,
range: TextRange,
) -> ScopedCallId {
let call_id = self.tracked_call_ranges.push(range);
self.tracked_calls_map.insert(expr.into(), call_id);
call_id
}
pub(super) fn finish(mut self) -> AstIds {
self.uses_map.shrink_to_fit();
self.tracked_calls_map.shrink_to_fit();
AstIds {
uses_map: self.uses_map,
tracked_calls_map: self.tracked_calls_map,
tracked_call_ranges: self.tracked_call_ranges,
}
}
}

View File

@@ -14,7 +14,7 @@ use ruff_python_ast::{self as ast, NodeIndex, PySourceType, PythonVersion};
use ruff_python_parser::semantic_errors::{
SemanticSyntaxChecker, SemanticSyntaxContext, SemanticSyntaxError, SemanticSyntaxErrorKind,
};
use ruff_text_size::TextRange;
use ruff_text_size::{Ranged, TextRange};
use ty_module_resolver::{ModuleName, resolve_module};
use crate::ast_node_ref::AstNodeRef;
@@ -2741,6 +2741,17 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
}
walk_expr(self, expr);
}
ast::Expr::Call(call_expr) => {
// Track potential synthesized-type calls for stable identity.
// Assigned calls use `Definition` for identity; inline calls need a `ScopedCallId`.
if self.current_assignment().is_none()
&& is_potential_synthesized_type_call(call_expr)
{
self.current_ast_ids()
.record_call(call_expr, call_expr.range());
}
walk_expr(self, expr);
}
_ => {
walk_expr(self, expr);
}
@@ -3195,3 +3206,31 @@ fn is_if_not_type_checking(expr: &ast::Expr) -> bool {
}) if is_if_type_checking(operand)
)
}
/// Returns whether a call expression might create a synthesized type.
///
/// This is a heuristic used during semantic indexing to assign stable IDs
/// to calls that may produce `NamedTuple`, `TypedDict`, `type()` classes, etc.
/// False positives are acceptable (the ID just won't be used during inference).
fn is_potential_synthesized_type_call(call: &ast::ExprCall) -> bool {
// Check for `type(...)` or `builtins.type(...)`
let is_type_call = match call.func.as_ref() {
ast::Expr::Name(name) => name.id.as_str() == "type",
ast::Expr::Attribute(attr) => {
attr.attr.as_str() == "type"
&& matches!(attr.value.as_ref(), ast::Expr::Name(name) if name.id.as_str() == "builtins")
}
_ => false,
};
if is_type_call {
// type("Name", bases, dict)
return call.arguments.keywords.is_empty() && call.arguments.args.len() == 3;
}
// TODO: Add more patterns as we support them:
// - NamedTuple("Name", [...]) or NamedTuple("Name", field1=type1, ...)
// - TypedDict("Name", {...}) or TypedDict("Name", field1=type1, ...)
false
}

View File

@@ -6529,9 +6529,9 @@ impl<'db> Type<'db> {
Some(TypeDefinition::Function(function.definition(db)))
}
Self::ModuleLiteral(module) => Some(TypeDefinition::Module(module.module(db))),
Self::ClassLiteral(class_literal) => Some(class_literal.type_definition(db)),
Self::ClassLiteral(class_literal) => class_literal.type_definition(db),
Self::GenericAlias(alias) => Some(TypeDefinition::StaticClass(alias.definition(db))),
Self::NominalInstance(instance) => Some(instance.class(db).type_definition(db)),
Self::NominalInstance(instance) => instance.class(db).type_definition(db),
Self::KnownInstance(instance) => match instance {
KnownInstanceType::TypeVar(var) => {
Some(TypeDefinition::TypeVar(var.definition(db)?))
@@ -6545,7 +6545,7 @@ impl<'db> Type<'db> {
Self::SubclassOf(subclass_of_type) => match subclass_of_type.subclass_of() {
SubclassOfInner::Dynamic(_) => None,
SubclassOfInner::Class(class) => Some(class.type_definition(db)),
SubclassOfInner::Class(class) => class.type_definition(db),
SubclassOfInner::TypeVar(bound_typevar) => {
Some(TypeDefinition::TypeVar(bound_typevar.typevar(db).definition(db)?))
}
@@ -6575,7 +6575,7 @@ impl<'db> Type<'db> {
Self::TypeVar(bound_typevar) => Some(TypeDefinition::TypeVar(bound_typevar.typevar(db).definition(db)?)),
Self::ProtocolInstance(protocol) => match protocol.inner {
Protocol::FromClass(class) => Some(class.type_definition(db)),
Protocol::FromClass(class) => class.type_definition(db),
Protocol::Synthesized(_) => None,
},

View File

@@ -10,11 +10,13 @@ use super::{
function::FunctionType,
};
use crate::place::{DefinedPlace, TypeOrigin};
use crate::semantic_index::definition::{Definition, DefinitionState};
use crate::semantic_index::scope::{NodeWithScopeKind, Scope, ScopeKind};
use crate::semantic_index::ast_ids::ScopedCallId;
use crate::semantic_index::definition::{Definition, DefinitionKind, DefinitionState};
use crate::semantic_index::scope::{FileScopeId, NodeWithScopeKind, Scope, ScopeKind};
use crate::semantic_index::symbol::Symbol;
use crate::semantic_index::{
DeclarationWithConstraint, SemanticIndex, attribute_declarations, attribute_scopes,
semantic_index,
};
use crate::types::bound_super::BoundSuperError;
use crate::types::constraints::{ConstraintSet, IteratorConstraintsExtension};
@@ -57,11 +59,7 @@ use crate::{
known_module_symbol, place_from_bindings, place_from_declarations,
},
semantic_index::{
attribute_assignments,
definition::{DefinitionKind, TargetKind},
place_table,
scope::{FileScopeId, ScopeId},
semantic_index, use_def_map,
attribute_assignments, definition::TargetKind, place_table, scope::ScopeId, use_def_map,
},
types::{
CallArguments, CallError, CallErrorKind, MetaclassCandidate, TypeDefinition, UnionType,
@@ -668,10 +666,10 @@ impl<'db> ClassLiteral<'db> {
}
}
/// Returns the definition of this class.
pub(crate) fn definition(self, db: &'db dyn Db) -> Definition<'db> {
/// Returns the definition of this class, if available.
pub(crate) fn definition(self, db: &'db dyn Db) -> Option<Definition<'db>> {
match self {
Self::Static(class) => class.definition(db),
Self::Static(class) => Some(class.definition(db)),
Self::Dynamic(class) => class.definition(db),
}
}
@@ -679,11 +677,11 @@ impl<'db> ClassLiteral<'db> {
/// Returns the type definition for this class.
///
/// For static classes, returns `TypeDefinition::StaticClass`.
/// For dynamic classes, returns `TypeDefinition::DynamicClass`.
pub(crate) fn type_definition(self, db: &'db dyn Db) -> TypeDefinition<'db> {
/// For dynamic classes, returns `TypeDefinition::DynamicClass` if a definition is available.
pub(crate) fn type_definition(self, db: &'db dyn Db) -> Option<TypeDefinition<'db>> {
match self {
Self::Static(class) => TypeDefinition::StaticClass(class.definition(db)),
Self::Dynamic(class) => TypeDefinition::DynamicClass(class.definition(db)),
Self::Static(class) => Some(TypeDefinition::StaticClass(class.definition(db))),
Self::Dynamic(class) => class.definition(db).map(TypeDefinition::DynamicClass),
}
}
@@ -941,13 +939,13 @@ impl<'db> ClassType<'db> {
self.class_literal(db).known(db)
}
/// Returns the definition for this class.
pub(crate) fn definition(self, db: &'db dyn Db) -> Definition<'db> {
/// Returns the definition for this class, if available.
pub(crate) fn definition(self, db: &'db dyn Db) -> Option<Definition<'db>> {
self.class_literal(db).definition(db)
}
/// Returns the type definition for this class.
pub(crate) fn type_definition(self, db: &'db dyn Db) -> TypeDefinition<'db> {
pub(crate) fn type_definition(self, db: &'db dyn Db) -> Option<TypeDefinition<'db>> {
self.class_literal(db).type_definition(db)
}
@@ -4680,21 +4678,18 @@ impl<'db> VarianceInferable<'db> for ClassLiteral<'db> {
///
/// # Salsa interning
///
/// Each `type()` call is uniquely identified by its [`Definition`], which provides
/// stable identity without depending on AST node indices that can change when code
/// is inserted above the call site.
/// Each `type()` call is uniquely identified by its [`DynamicClassOrigin`], which provides
/// stable identity without depending on AST node indices that can change when code is
/// inserted above the call site.
///
/// Two different `type()` calls always produce distinct `DynamicClassLiteral`
/// instances, even if they have the same name and bases:
/// Two different `type()` calls always produce distinct `DynamicClassLiteral` instances,
/// even if they have the same name and bases:
///
/// ```python
/// Foo1 = type("Foo", (Base,), {})
/// Foo2 = type("Foo", (Base,), {})
/// # Foo1 and Foo2 are distinct types
/// ```
///
/// Note: Only assigned `type()` calls are currently supported (e.g., `Foo = type(...)`).
/// Inline calls like `process(type(...))` fall back to normal call handling.
#[salsa::interned(debug, heap_size = ruff_memory_usage::heap_size)]
#[derive(PartialOrd, Ord)]
pub struct DynamicClassLiteral<'db> {
@@ -4706,14 +4701,38 @@ pub struct DynamicClassLiteral<'db> {
#[returns(deref)]
pub bases: Box<[ClassBase<'db>]>,
/// The definition where this class is created.
pub definition: Definition<'db>,
/// The origin of this dynamic class, providing stable identity.
pub origin: DynamicClassOrigin<'db>,
/// Dataclass parameters if this class has been wrapped with `@dataclass` decorator
/// or passed to `dataclass()` as a function.
pub dataclass_params: Option<DataclassParams<'db>>,
}
/// The origin of a dynamically created class, used for stable identity in Salsa.
///
/// This enum provides stable identification for `type()` calls without relying on
/// AST node indices that change when code is inserted above the call site.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update)]
pub enum DynamicClassOrigin<'db> {
/// A `type()` call that is assigned to a variable (e.g., `Foo = type("Foo", (), {})`).
///
/// The Definition provides stable identity via its `ScopedPlaceId`.
Assigned(Definition<'db>),
/// An inline `type()` call not assigned to a variable (e.g., `process(type("Foo", (), {}))`).
///
/// Uses file, scope, and a scoped call ID for stable identity.
/// The ID is assigned sequentially during semantic indexing.
Inline {
file: File,
file_scope: FileScopeId,
call_id: ScopedCallId,
},
}
impl get_size2::GetSize for DynamicClassOrigin<'_> {}
impl get_size2::GetSize for DynamicClassLiteral<'_> {}
#[salsa::tracked]
@@ -4727,25 +4746,52 @@ impl<'db> DynamicClassLiteral<'db> {
/// Returns the range of the `type()` call expression that created this class.
pub(super) fn header_range(self, db: &'db dyn Db) -> TextRange {
let definition = self.definition(db);
let file = definition.file(db);
let module = parsed_module(db, file).load(db);
// Dynamic classes are only created from regular assignments (e.g., `Foo = type(...)`).
let DefinitionKind::Assignment(assignment) = definition.kind(db) else {
unreachable!("DynamicClassLiteral should only be created from Assignment definitions");
};
assignment.value(&module).range()
match self.origin(db) {
DynamicClassOrigin::Assigned(definition) => {
// For assigned calls, get the range from the assignment value.
let file = definition.file(db);
let module = parsed_module(db, file).load(db);
let DefinitionKind::Assignment(assignment) = definition.kind(db) else {
unreachable!(
"DynamicClassOrigin::Assigned should only be created from Assignment definitions"
);
};
assignment.value(&module).range()
}
DynamicClassOrigin::Inline {
file,
file_scope,
call_id,
} => {
// For inline calls, look up the range from the semantic index.
let index = semantic_index(db, file);
index.ast_ids(file_scope).call_range(call_id)
}
}
}
/// Returns the file containing the `type()` call.
pub(crate) fn file(self, db: &'db dyn Db) -> File {
self.definition(db).file(db)
match self.origin(db) {
DynamicClassOrigin::Assigned(definition) => definition.file(db),
DynamicClassOrigin::Inline { file, .. } => file,
}
}
/// Returns the scope containing the `type()` call.
pub(crate) fn file_scope(self, db: &'db dyn Db) -> FileScopeId {
self.definition(db).file_scope(db)
match self.origin(db) {
DynamicClassOrigin::Assigned(definition) => definition.file_scope(db),
DynamicClassOrigin::Inline { file_scope, .. } => file_scope,
}
}
/// Returns the definition where this class is created, if in an assignment context.
pub(crate) fn definition(self, db: &'db dyn Db) -> Option<Definition<'db>> {
match self.origin(db) {
DynamicClassOrigin::Assigned(definition) => Some(definition),
DynamicClassOrigin::Inline { .. } => None,
}
}
/// Get the metaclass of this dynamic class.
@@ -4919,7 +4965,7 @@ impl<'db> DynamicClassLiteral<'db> {
db,
self.name(db).clone(),
self.bases(db),
self.definition(db),
self.origin(db),
dataclass_params,
)
}

View File

@@ -96,7 +96,7 @@ pub(crate) fn typing_self<'db>(
let identity = TypeVarIdentity::new(
db,
ast::name::Name::new_static("Self"),
Some(class.definition(db)),
class.definition(db),
TypeVarKind::TypingSelf,
);
let bounds = TypeVarBoundOrConstraints::UpperBound(Type::instance(

View File

@@ -169,9 +169,9 @@ pub fn definitions_for_name<'db>(
// instead of `int` (hover only shows the docstring of the first definition).
.rev()
.filter_map(|ty| ty.as_nominal_instance())
.map(|instance| {
let definition = instance.class_literal(db).definition(db);
ResolvedDefinition::Definition(definition)
.filter_map(|instance| {
let definition = instance.class_literal(db).definition(db)?;
Some(ResolvedDefinition::Definition(definition))
})
.collect();
}

View File

@@ -48,14 +48,14 @@ use crate::semantic_index::scope::{
};
use crate::semantic_index::symbol::{ScopedSymbolId, Symbol};
use crate::semantic_index::{
ApplicableConstraints, EnclosingSnapshotResult, SemanticIndex, place_table,
ApplicableConstraints, EnclosingSnapshotResult, SemanticIndex, place_table, semantic_index,
};
use crate::subscript::{PyIndex, PySlice};
use crate::types::call::bind::{CallableDescription, MatchingOverloadIndex};
use crate::types::call::{Binding, Bindings, CallArguments, CallError, CallErrorKind};
use crate::types::class::{
ClassLiteral, CodeGeneratorKind, DynamicClassLiteral, DynamicMetaclassConflict, FieldKind,
MetaclassErrorKind, MethodDecorator,
ClassLiteral, CodeGeneratorKind, DynamicClassLiteral, DynamicClassOrigin,
DynamicMetaclassConflict, FieldKind, MetaclassErrorKind, MethodDecorator,
};
use crate::types::context::{InNoTypeCheck, InferContext};
use crate::types::cyclic::CycleDetector;
@@ -5412,7 +5412,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
// Try to extract the dynamic class with definition.
// This returns `None` if it's not a three-arg call to `type()`,
// signalling that we must fall back to normal call inference.
self.infer_dynamic_type_expression(call_expr, definition)
self.infer_dynamic_type_expression(call_expr, Some(definition))
.unwrap_or_else(|| {
self.infer_call_expression_impl(call_expr, callable_type, tcx)
})
@@ -6031,7 +6031,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
fn infer_dynamic_type_expression(
&mut self,
call_expr: &ast::ExprCall,
definition: Definition<'db>,
definition: Option<Definition<'db>>,
) -> Option<Type<'db>> {
let db = self.db();
@@ -6096,7 +6096,29 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
let bases = self.extract_dynamic_type_bases(bases_arg, bases_type, &name);
let dynamic_class = DynamicClassLiteral::new(db, name, bases, definition, None);
// Get the origin for this dynamic class.
let origin = if let Some(def) = definition {
// Assigned call: use the definition for stable identity.
DynamicClassOrigin::Assigned(def)
} else {
// Inline call: look up the ScopedCallId from semantic indexing.
let file = self.file();
let file_scope = self.scope().file_scope_id(db);
let index = semantic_index(db, file);
let Some(call_id) = index.ast_ids(file_scope).try_call_id(call_expr) else {
// If no call ID was tracked for this call during semantic indexing,
// we can't create a stable DynamicClassLiteral. Fall back to regular
// type inference.
return None;
};
DynamicClassOrigin::Inline {
file,
file_scope,
call_id,
}
};
let dynamic_class = DynamicClassLiteral::new(db, name, bases, origin, None);
// Check for MRO errors.
if let Err(error) = dynamic_class.try_mro(db) {
@@ -9079,6 +9101,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
return Type::TypedDict(typed_dict);
}
// Handle 3-argument `type(name, bases, dict)`.
if let Type::ClassLiteral(class) = callable_type
&& class.is_known(self.db(), KnownClass::Type)
&& let Some(dynamic_type) = self.infer_dynamic_type_expression(call_expression, None)
{
return dynamic_type;
}
// We don't call `Type::try_call`, because we want to perform type inference on the
// arguments after matching them to parameters, but before checking that the argument types
// are assignable to any parameter annotations.

View File

@@ -303,14 +303,14 @@ impl<'db> TypedDictType<'db> {
pub fn definition(self, db: &'db dyn Db) -> Option<Definition<'db>> {
match self {
TypedDictType::Class(defining_class) => Some(defining_class.definition(db)),
TypedDictType::Class(defining_class) => defining_class.definition(db),
TypedDictType::Synthesized(_) => None,
}
}
pub fn type_definition(self, db: &'db dyn Db) -> Option<TypeDefinition<'db>> {
match self {
TypedDictType::Class(defining_class) => Some(defining_class.type_definition(db)),
TypedDictType::Class(defining_class) => defining_class.type_definition(db),
TypedDictType::Synthesized(_) => None,
}
}