From c43066cfd1e8072f622e840bdf3ed2083dfb5e1f Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 16 Jan 2026 10:43:42 -0500 Subject: [PATCH 1/4] [ty] Support deferred field evaluation for dynamic NamedTuple(...) --- .../resources/mdtest/named_tuple.md | 101 +++++ crates/ty_python_semantic/src/types/class.rs | 402 +++++++++++++++++- .../src/types/infer/builder.rs | 292 +++++++------ 3 files changed, 666 insertions(+), 129 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index 42602a23272823..b47abd0ab28cb4 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -127,6 +127,107 @@ reveal_type(alice5.id) # revealed: int reveal_type(alice5.name) # revealed: str ``` +### Functional syntax with string annotations + +String annotations (forward references) are properly evaluated to types: + +```py +from typing import NamedTuple + +Point = NamedTuple("Point", [("x", "int"), ("y", "int")]) +p = Point(1, 2) + +reveal_type(p.x) # revealed: int +reveal_type(p.y) # revealed: int +``` + +Recursive references in functional syntax are supported: + +```py +from typing import NamedTuple + +Node = NamedTuple("Node", [("value", int), ("next", "Node | None")]) +n = Node(1, None) + +reveal_type(n.value) # revealed: int +reveal_type(n.next) # revealed: Node | None +``` + +### Functional syntax with tuple literal + +Using a tuple literal for fields instead of a list: + +```py +from typing import NamedTuple + +Point = NamedTuple("Point", (("x", int), ("y", int))) +p = Point(1, 2) + +reveal_type(p.x) # revealed: int +reveal_type(p.y) # revealed: int +``` + +### Functional syntax with variable fields and string annotations + +String annotations in variable fields are properly resolved: + +```py +from typing import NamedTuple + +fields = [("value", "int"), ("label", "str")] +Item = NamedTuple("Item", fields) +item = Item(42, "test") + +reveal_type(item.value) # revealed: int +reveal_type(item.label) # revealed: str +``` + +Recursive string annotations also work when fields come from a variable: + +```py +from typing import NamedTuple + +tree_fields = [("value", int), ("left", "Tree | None"), ("right", "Tree | None")] +Tree = NamedTuple("Tree", tree_fields) +t = Tree(1, None, None) + +reveal_type(t.value) # revealed: int +reveal_type(t.left) # revealed: Tree | None +reveal_type(t.right) # revealed: Tree | None +``` + +### Functional syntax as base class (dangling call) + +When NamedTuple is used directly as a base class without being assigned to a variable first, it's a +"dangling call". The types are still properly inferred: + +```py +from typing import NamedTuple + +class Point(NamedTuple("Point", [("x", int), ("y", int)])): + def magnitude(self) -> float: + return (self.x**2 + self.y**2) ** 0.5 + +p = Point(3, 4) +reveal_type(p.x) # revealed: int +reveal_type(p.y) # revealed: int +reveal_type(p.magnitude()) # revealed: int | float +``` + +String annotations in dangling calls currently don't resolve properly (this is a known limitation): + +```py +from typing import NamedTuple + +class Node(NamedTuple("Node", [("value", int), ("next", "Node | None")])): + pass + +n = Node(1, None) +reveal_type(n.value) # revealed: int +# TODO: This should be `Node | None`, but string annotations in dangling calls aren't resolved. +reveal_type(n.next) # revealed: Unknown +``` + ### Functional syntax with variable name When the typename is passed via a variable, we can extract it from the inferred literal string type: diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 71e1a6edecaf3e..b58f7d1dfb7bfe 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -3,12 +3,12 @@ use std::cell::RefCell; use std::fmt::Write; use std::sync::{LazyLock, Mutex}; -use super::TypeVarVariance; use super::{ BoundTypeVarInstance, MemberLookupPolicy, Mro, MroIterator, SpecialFormType, StaticMroError, SubclassOfType, Truthiness, Type, TypeQualifiers, class_base::ClassBase, function::FunctionType, }; +use super::{TypeVarVariance, infer_deferred_types}; use crate::place::{DefinedPlace, TypeOrigin}; use crate::semantic_index::definition::{Definition, DefinitionState}; use crate::semantic_index::scope::{NodeWithScopeKind, Scope, ScopeKind}; @@ -172,6 +172,29 @@ fn fields_cycle_initial<'db>( FxIndexMap::default() } +/// Cycle initial function for `DynamicNamedTupleLiteral::mro`. +/// +/// Returns an MRO using `Unknown` types for the tuple element types, +/// which breaks the cycle while providing a valid fallback. +fn dynamic_namedtuple_mro_cycle_initial<'db>( + db: &'db dyn Db, + _id: salsa::Id, + self_: DynamicNamedTupleLiteral<'db>, +) -> Mro<'db> { + let self_base = ClassBase::Class(ClassType::NonGeneric(self_.into())); + let tuple_class = self_.tuple_base_class_for_mro(db); + let object_class = KnownClass::Object + .to_class_literal(db) + .as_class_literal() + .expect("object should be a class literal") + .default_specialization(db); + Mro::from([ + self_base, + ClassBase::Class(tuple_class), + ClassBase::Class(object_class), + ]) +} + /// A category of classes with code generation capabilities (with synthesized methods). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] pub(crate) enum CodeGeneratorKind<'db> { @@ -5299,12 +5322,27 @@ pub struct DynamicNamedTupleLiteral<'db> { #[returns(ref)] pub name: Name, - /// The fields as (name, type, default) tuples. + /// The field names for this namedtuple. + /// + /// For `typing.NamedTuple`, field types are computed lazily in `fields()` + /// by re-reading the AST and using deferred inference. This supports + /// recursive namedtuples where field types may reference the namedtuple being defined. + /// /// For `collections.namedtuple`, all types are `Any`. - /// For `typing.NamedTuple`, types come from the field definitions. - /// The third element is the default type, if any. #[returns(ref)] - pub fields: Box<[(Name, Type<'db>, Option>)]>, + pub raw_fields: Box<[Name]>, + + /// The kind of namedtuple (`typing.NamedTuple` or `collections.namedtuple`). + pub kind: NamedTupleKind, + + /// The default value types for fields with defaults. + /// + /// For `collections.namedtuple`, these come from the `defaults` parameter. + /// For `typing.NamedTuple` (functional form), defaults are not supported. + /// The defaults apply to the rightmost fields (i.e., the last N fields + /// where N is the length of this slice). + #[returns(ref)] + pub default_types: Box<[Type<'db>]>, /// Whether the fields are known statically. /// @@ -5386,12 +5424,28 @@ impl<'db> DynamicNamedTupleLiteral<'db> { Span::from(self.scope(db).file(db)).with_range(self.header_range(db)) } + /// Compute field types for this namedtuple. + /// + /// For `typing.NamedTuple`, field types are computed lazily using deferred inference. + /// For `collections.namedtuple`, all field types are `Any`. + /// + /// Returns `(name, type, default_type)` tuples where `default_type` is `Some` for + /// fields with defaults (only for `collections.namedtuple` via the `defaults` parameter). + #[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] + pub(crate) fn fields(self, db: &'db dyn Db) -> Box<[(Name, Type<'db>, Option>)]> { + dynamic_namedtuple_fields(db, self) + } + /// Compute the MRO for this namedtuple. /// /// The MRO is `[self, tuple[field_types...], object]`. /// For example, `namedtuple("Point", [("x", int), ("y", int)])` has MRO /// `[Point, tuple[int, int], object]`. - #[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] + #[salsa::tracked( + returns(ref), + cycle_initial = dynamic_namedtuple_mro_cycle_initial, + heap_size = ruff_memory_usage::heap_size + )] pub(crate) fn mro(self, db: &'db dyn Db) -> Mro<'db> { let self_base = ClassBase::Class(ClassType::NonGeneric(self.into())); let tuple_class = self.tuple_base_class(db); @@ -5415,9 +5469,49 @@ impl<'db> DynamicNamedTupleLiteral<'db> { KnownClass::Type.to_class_literal(db) } - /// Compute the specialized tuple class that this namedtuple inherits from. + /// Compute the specialized tuple class that this namedtuple inherits from for MRO purposes. /// /// For example, `namedtuple("Point", [("x", int), ("y", int)])` inherits from `tuple[int, int]`. + /// + /// Note: This method does NOT call `fields()` to avoid cycles during definition inference. + /// Instead, it uses the raw field count to create a tuple with the right arity. + /// For the actual field types, use `fields()` which computes them lazily. + fn tuple_base_class_for_mro(self, db: &'db dyn Db) -> ClassType<'db> { + // If fields are unknown, return `tuple[Unknown, ...]` to avoid false positives + // like index-out-of-bounds errors. + if !self.has_known_fields(db) { + return TupleType::homogeneous(db, Type::unknown()).to_class_type(db); + } + + // Use the raw field count to determine the tuple arity. + // We don't compute actual field types here to avoid cycles. + let raw_fields = self.raw_fields(db); + let field_count = raw_fields.len(); + + // For typing.NamedTuple, we need to compute types lazily. + // For collections.namedtuple, all types are Any. + let element_type = if self.kind(db) == NamedTupleKind::Collections { + Type::any() + } else { + Type::unknown() + }; + + let field_types = std::iter::repeat_n(element_type, field_count); + TupleType::heterogeneous(db, field_types) + .map(|t| t.to_class_type(db)) + .unwrap_or_else(|| { + KnownClass::Tuple + .to_class_literal(db) + .as_class_literal() + .expect("tuple should be a class literal") + .default_specialization(db) + }) + } + + /// Compute the specialized tuple class with actual field types. + /// + /// This method calls `fields()` to get the actual field types. + /// Use `tuple_base_class_for_mro()` for MRO computation to avoid cycles. pub(crate) fn tuple_base_class(self, db: &'db dyn Db) -> ClassType<'db> { // If fields are unknown, return `tuple[Unknown, ...]` to avoid false positives // like index-out-of-bounds errors. @@ -5578,6 +5672,260 @@ impl<'db> DynamicNamedTupleLiteral<'db> { } } +/// Compute field types for a dynamic namedtuple. +/// +/// For `typing.NamedTuple`, field types are computed using deferred inference to support +/// recursive namedtuples and proper string annotation handling. +/// For `collections.namedtuple`, all field types are `Any`. +fn dynamic_namedtuple_fields<'db>( + db: &'db dyn Db, + namedtuple: DynamicNamedTupleLiteral<'db>, +) -> Box<[(Name, Type<'db>, Option>)]> { + use crate::types::infer::infer_scope_types; + + let raw_fields = namedtuple.raw_fields(db); + let kind = namedtuple.kind(db); + let default_types = namedtuple.default_types(db); + let num_defaults = default_types.len(); + let num_fields = raw_fields.len(); + + // For collections.namedtuple, all types are Any. + if kind == NamedTupleKind::Collections { + return raw_fields + .iter() + .enumerate() + .map(|(i, name)| { + let default_ty = if num_defaults > 0 && i >= num_fields - num_defaults { + // Get the actual default type from the stored defaults. + let default_index = i - (num_fields - num_defaults); + Some(default_types[default_index]) + } else { + None + }; + (name.clone(), Type::any(), default_ty) + }) + .collect(); + } + + // For typing.NamedTuple, compute field types. + // For assigned calls (with definition), use deferred inference for recursive support. + // For dangling calls (without definition), use regular expression inference. + + let scope = namedtuple.scope(db); + let file = scope.file(db); + let module = parsed_module(db, file).load(db); + + // Get the call expression, either from the definition or from the scope offset. + let call_expr: &ast::ExprCall = match namedtuple.anchor(db) { + DynamicClassAnchor::Definition(definition) => { + let Some(expr) = definition.kind(db).value(&module) else { + return raw_fields + .iter() + .cloned() + .map(|name| (name, Type::unknown(), None)) + .collect(); + }; + match expr { + ast::Expr::Call(call) => call, + _ => { + return raw_fields + .iter() + .cloned() + .map(|name| (name, Type::unknown(), None)) + .collect(); + } + } + } + DynamicClassAnchor::ScopeOffset { offset, .. } => { + let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); + let anchor_u32 = scope_anchor + .as_u32() + .expect("anchor should not be NodeIndex::NONE"); + let absolute_index = NodeIndex::from(anchor_u32 + offset); + module + .get_by_index(absolute_index) + .try_into() + .expect("scope offset should point to ExprCall") + } + }; + + // The second positional argument should be the fields list/tuple. + let Some(fields_arg) = call_expr.arguments.args.get(1) else { + return raw_fields + .iter() + .cloned() + .map(|name| (name, Type::unknown(), None)) + .collect(); + }; + + // Try to get field type expressions from AST literal. + // If the fields argument is not a literal (e.g., a variable), we'll fall back to + // extracting types from the inferred type. + let elements: Option<&[ast::Expr]> = match fields_arg { + ast::Expr::List(list) => Some(&list.elts), + ast::Expr::Tuple(tuple) => Some(&tuple.elts), + _ => None, + }; + + // Build a mapping from field names to their type expressions (for AST literals). + let mut field_type_exprs: std::collections::BTreeMap<&str, &ast::Expr> = + std::collections::BTreeMap::new(); + if let Some(elements) = elements { + for elt in elements { + // Each element should be a tuple like ("field_name", type). + let field_spec_elts: &[ast::Expr] = match elt { + ast::Expr::Tuple(tuple) => &tuple.elts, + ast::Expr::List(list) => &list.elts, + _ => continue, + }; + if field_spec_elts.len() != 2 { + continue; + } + + // First element: field name (should be a string literal). + let field_name_expr = &field_spec_elts[0]; + if let ast::Expr::StringLiteral(string_lit) = field_name_expr { + let field_name = string_lit.value.to_str(); + let field_type_expr = &field_spec_elts[1]; + field_type_exprs.insert(field_name, field_type_expr); + } + } + } + + // Build fields by looking up types. + // For definitions with AST literal fields, use deferred inference to support recursion. + // For dangling calls or variable fields, use scope inference. + if let Some(definition) = namedtuple.definition(db) { + // Check if all fields have AST type expressions. + let all_fields_have_ast = raw_fields + .iter() + .all(|name| field_type_exprs.contains_key(name.as_str())); + + if all_fields_have_ast { + let deferred_inference = infer_deferred_types(db, definition); + return raw_fields + .iter() + .cloned() + .map(|name| { + let field_ty = field_type_exprs + .get(name.as_str()) + .map(|type_expr| { + deferred_inference + .try_expression_type(*type_expr) + .unwrap_or_else(Type::unknown) + }) + .unwrap_or_else(Type::unknown); + // typing.NamedTuple functional form doesn't support defaults. + (name, field_ty, None) + }) + .collect(); + } + + // Some fields come from variables; we need scope inference for those. + let scope_inference = infer_scope_types(db, scope); + let fields_type = scope_inference.expression_type(fields_arg); + + // Build a mapping from field names to their types (from the inferred type). + let mut field_types_from_inferred: std::collections::BTreeMap<&str, Type<'db>> = + std::collections::BTreeMap::new(); + if let Some(tuple_spec) = fields_type.tuple_instance_spec(db) + && let Some(fixed_tuple) = tuple_spec.as_fixed_length() + { + for field_tuple in fixed_tuple.all_elements() { + // Each field must be a fixed-length tuple of exactly 2 elements. + if let Some(field_spec) = field_tuple.exact_tuple_instance_spec(db) + && let Some(field_fixed) = field_spec.as_fixed_length() + && let [Type::StringLiteral(name_lit), field_type] = field_fixed.all_elements() + { + let field_name = name_lit.value(db); + // Convert value type to type expression. + let field_ty = field_type + .in_type_expression(db, scope, None) + .unwrap_or_else(|error| error.fallback_type); + field_types_from_inferred.insert(field_name, field_ty); + } + } + } + + let deferred_inference = infer_deferred_types(db, definition); + raw_fields + .iter() + .cloned() + .map(|name| { + let field_ty = if let Some(type_expr) = field_type_exprs.get(name.as_str()) { + // Get the inferred type from deferred inference. + deferred_inference + .try_expression_type(*type_expr) + .unwrap_or_else(|| { + // Fall back to inferred type from variable. + field_types_from_inferred + .get(name.as_str()) + .copied() + .unwrap_or_else(Type::unknown) + }) + } else { + // No AST expression available, use inferred type from variable. + field_types_from_inferred + .get(name.as_str()) + .copied() + .unwrap_or_else(Type::unknown) + }; + // typing.NamedTuple functional form doesn't support defaults. + (name, field_ty, None) + }) + .collect() + } else { + // For dangling calls (e.g., used as base class), use scope inference. + let scope_inference = infer_scope_types(db, scope); + let fields_type = scope_inference.expression_type(fields_arg); + + // Build a mapping from field names to their types (from the inferred type). + let mut field_types_from_inferred: std::collections::BTreeMap<&str, Type<'db>> = + std::collections::BTreeMap::new(); + if let Some(tuple_spec) = fields_type.tuple_instance_spec(db) + && let Some(fixed_tuple) = tuple_spec.as_fixed_length() + { + for field_tuple in fixed_tuple.all_elements() { + // Each field must be a fixed-length tuple of exactly 2 elements. + if let Some(field_spec) = field_tuple.exact_tuple_instance_spec(db) + && let Some(field_fixed) = field_spec.as_fixed_length() + && let [Type::StringLiteral(name_lit), field_type] = field_fixed.all_elements() + { + let field_name = name_lit.value(db); + // Convert value type to type expression. + let field_ty = field_type + .in_type_expression(db, scope, None) + .unwrap_or_else(|error| error.fallback_type); + field_types_from_inferred.insert(field_name, field_ty); + } + } + } + + raw_fields + .iter() + .cloned() + .map(|name| { + let field_ty = if let Some(type_expr) = field_type_exprs.get(name.as_str()) { + // Get the inferred type from scope inference. + let value_ty = scope_inference.expression_type(*type_expr); + // Convert value type to type (e.g., class literal to instance). + value_ty + .in_type_expression(db, scope, None) + .unwrap_or_else(|error| error.fallback_type) + } else { + // No AST expression available, use inferred type from variable. + field_types_from_inferred + .get(name.as_str()) + .copied() + .unwrap_or_else(Type::unknown) + }; + // typing.NamedTuple functional form doesn't support defaults. + (name, field_ty, None) + }) + .collect() + } +} + /// Performs member lookups over an MRO (Method Resolution Order). /// /// This struct encapsulates the shared logic for looking up class and instance @@ -8035,3 +8383,43 @@ mod tests { } } } + +/// The kind of namedtuple: `typing.NamedTuple` or `collections.namedtuple`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, salsa::Update)] +pub enum NamedTupleKind { + /// `collections.namedtuple` - all field types are `Any`. + Collections, + /// `typing.NamedTuple` - field types come from type annotations. + Typing, +} + +impl get_size2::GetSize for NamedTupleKind {} + +impl NamedTupleKind { + pub(crate) const fn is_collections(self) -> bool { + matches!(self, Self::Collections) + } + + pub(crate) const fn is_typing(self) -> bool { + matches!(self, Self::Typing) + } + + pub(crate) fn from_type(db: &dyn Db, ty: Type) -> Option { + match ty { + Type::SpecialForm(SpecialFormType::NamedTuple) => Some(NamedTupleKind::Typing), + Type::FunctionLiteral(function) => function + .is_known(db, KnownFunction::NamedTuple) + .then_some(NamedTupleKind::Collections), + _ => None, + } + } +} + +impl std::fmt::Display for NamedTupleKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + NamedTupleKind::Collections => "namedtuple", + NamedTupleKind::Typing => "NamedTuple", + }) + } +} diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 1aa6555b3e95fb..0cbe38bbecc222 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -57,11 +57,11 @@ use crate::semantic_index::{ use crate::subscript::{PyIndex, PySlice}; use crate::types::call::bind::{CallableDescription, MatchingOverloadIndex}; use crate::types::call::{Argument, Binding, Bindings, CallArguments, CallError, CallErrorKind}; -use crate::types::class::DynamicNamedTupleLiteral; use crate::types::class::{ ClassLiteral, CodeGeneratorKind, DynamicClassAnchor, DynamicClassLiteral, DynamicMetaclassConflict, FieldKind, MetaclassErrorKind, MethodDecorator, }; +use crate::types::class::{DynamicNamedTupleLiteral, NamedTupleKind}; use crate::types::context::{InNoTypeCheck, InferContext}; use crate::types::cyclic::CycleDetector; use crate::types::diagnostic::{ @@ -6158,6 +6158,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_newtype_assignment_deferred(arguments); return; } + if matches!(func_ty, Type::SpecialForm(SpecialFormType::NamedTuple)) { + self.infer_functional_namedtuple_deferred(arguments); + return; + } for arg in arguments.args.iter().skip(1) { self.infer_type_expression(arg); } @@ -6507,11 +6511,47 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Type::ClassLiteral(ClassLiteral::Dynamic(dynamic_class)) } + /// Infer field types for functional `NamedTuple` in deferred phase. + /// + /// This is called during `infer_deferred_types` to infer field types after the `NamedTuple` + /// definition is complete. This enables support for recursive namedtuples and proper + /// handling of string annotations like `("x", "int")`. + fn infer_functional_namedtuple_deferred(&mut self, arguments: &ast::Arguments) { + // The second positional argument is the fields list/tuple (if present). + let Some(fields_arg) = arguments.args.get(1) else { + return; + }; + + // Get the elements from the list or tuple literal. + let elements: &[ast::Expr] = match fields_arg { + ast::Expr::List(list) => &list.elts, + ast::Expr::Tuple(tuple) => &tuple.elts, + _ => return, + }; + + // Infer each field's type expression in deferred mode. + for elt in elements { + // Each element should be a tuple like ("field_name", type). + let field_spec_elts: &[ast::Expr] = match elt { + ast::Expr::Tuple(tuple) => &tuple.elts, + ast::Expr::List(list) => &list.elts, + _ => continue, + }; + if field_spec_elts.len() == 2 { + // Infer the type expression (second element) as an annotation. + let field_type_expr = &field_spec_elts[1]; + self.infer_annotation_expression( + field_type_expr, + DeferredExpressionState::Deferred, + ); + } + } + } + /// Infer a `typing.NamedTuple(typename, fields)` or `collections.namedtuple(typename, field_names)` call. /// /// This method *does not* call `infer_expression` on the object being called; /// it is assumed that the type for this AST node has already been inferred before this method is called. - #[expect(clippy::type_complexity)] fn infer_namedtuple_call_expression( &mut self, call_expr: &ast::ExprCall, @@ -6742,27 +6782,27 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; // Handle fields based on which namedtuple variant. - let (fields, has_known_fields): (Box<[(Name, Type<'db>, Option>)]>, bool) = + // For typing.NamedTuple, we only extract field names here; types are computed lazily + // via deferred inference to support recursive namedtuples and string annotations. + // For collections.namedtuple, all types are Any, so we just need names and default types. + let (raw_fields, field_defaults, has_known_fields): (Box<[Name]>, Box<[Type<'db>]>, bool) = match kind { NamedTupleKind::Typing => { - let fields = self - .extract_typing_namedtuple_fields(fields_arg, fields_type) - .or_else(|| self.extract_typing_namedtuple_fields_from_ast(fields_arg)); + // Extract field names only (types will be computed lazily). + let field_names = self + .extract_typing_namedtuple_field_names(fields_arg, fields_type) + .or_else(|| { + self.extract_typing_namedtuple_field_names_from_ast(fields_arg) + }); // Validate field names if we have known fields. - if let Some(ref fields) = fields { - let field_names: Vec<_> = - fields.iter().map(|(name, _, _)| name.clone()).collect(); - self.report_invalid_namedtuple_field_names( - &field_names, - fields_arg, - NamedTupleKind::Typing, - ); + if let Some(ref names) = field_names { + self.report_invalid_namedtuple_field_names(names, fields_arg, kind); } // Emit diagnostic if the type is outright invalid (not an iterable) or // if we have a list/tuple literal with invalid field specs. - if fields.is_none() { + if field_names.is_none() { let iterable_any = KnownClass::Iterable.to_specialized_instance(db, &[Type::any()]); if !fields_type.is_assignable_to(db, iterable_any) { @@ -6814,8 +6854,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - let has_known_fields = fields.is_some(); - (fields.unwrap_or_default(), has_known_fields) + let has_known_fields = field_names.is_some(); + // typing.NamedTuple functional form doesn't support defaults. + ( + field_names.unwrap_or_default(), + Box::new([]), + has_known_fields, + ) } NamedTupleKind::Collections => { // `collections.namedtuple`: `field_names` is a list or tuple of strings, or a space or @@ -6881,7 +6926,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.report_invalid_namedtuple_field_names( &field_names, fields_arg, - NamedTupleKind::Collections, + kind, ); } else { // Apply rename logic. @@ -6900,9 +6945,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } let num_fields = field_names.len(); - let defaults_count = default_types.len(); - if defaults_count > num_fields + if default_types.len() > num_fields && let Some(defaults_kw) = defaults_kw && let Some(builder) = self.context.report_lint(&INVALID_NAMED_TUPLE, defaults_kw) @@ -6911,32 +6955,19 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { "Too many defaults for `namedtuple()`" )); diagnostic.set_primary_message(format_args!( - "Got {defaults_count} default values but only {num_fields} field names" + "Got {} default values but only {num_fields} field names", + default_types.len() )); diagnostic.info("This will raise `TypeError` at runtime"); } - let defaults_count = defaults_count.min(num_fields); - let fields = field_names - .iter() - .enumerate() - .map(|(i, field_name)| { - let default = - if defaults_count > 0 && i >= num_fields - defaults_count { - // Index into default_types: first default corresponds to first - // field that has a default. - let default_idx = i - (num_fields - defaults_count); - Some(default_types[default_idx]) - } else { - None - }; - (field_name.clone(), Type::any(), default) - }) - .collect(); - (fields, true) + // Truncate defaults to at most num_fields (extra defaults are an error above). + let truncated_defaults: Box<[Type<'db>]> = + default_types.into_iter().take(num_fields).collect(); + (field_names, truncated_defaults, true) } else { // Couldn't determine fields statically; attribute lookups will return Any. - (Box::new([]), false) + (Box::new([]), Box::new([]), false) } } }; @@ -6963,26 +6994,36 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } }; - let namedtuple = DynamicNamedTupleLiteral::new(db, name, fields, has_known_fields, anchor); + let namedtuple = DynamicNamedTupleLiteral::new( + db, + name, + raw_fields, + kind, + field_defaults, + has_known_fields, + anchor, + ); Type::ClassLiteral(ClassLiteral::DynamicNamedTuple(namedtuple)) } - /// Extract fields from a typing.NamedTuple fields argument. - #[expect(clippy::type_complexity)] - fn extract_typing_namedtuple_fields( - &mut self, + /// Extract field names from a `typing.NamedTuple` fields argument. + /// + /// Only extracts field names; types are computed lazily via deferred inference + /// to support recursive namedtuples and string annotations. + fn extract_typing_namedtuple_field_names( + &self, fields_arg: &ast::Expr, fields_type: Type<'db>, - ) -> Option, Option>)]>> { + ) -> Option> { let db = self.db(); - let scope_id = self.scope(); - let typevar_binding_context = self.typevar_binding_context; // Try to extract from a fixed-length tuple type. let tuple_spec = fields_type.tuple_instance_spec(db)?; let fixed_tuple = tuple_spec.as_fixed_length()?; - let fields: Option> = fixed_tuple + + // Extract field names and validate types from the inferred type. + let field_names: Option> = fixed_tuple .all_elements() .iter() .map(|field_tuple| { @@ -6992,28 +7033,70 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let [Type::StringLiteral(name), field_type] = field_fixed.all_elements() else { return None; }; - // Convert value types to type expression types (e.g., class literals to instances). - let resolved_ty = - match field_type.in_type_expression(db, scope_id, typevar_binding_context) { - Ok(ty) => ty, - Err(error) => { - // Report diagnostic for invalid type expression. - if let Some(builder) = - self.context.report_lint(&INVALID_TYPE_FORM, fields_arg) + + // Validate the field type - report diagnostic if invalid. + if field_type + .in_type_expression(db, self.scope(), self.typevar_binding_context) + .is_err() + { + // Report diagnostic at the fields argument (best we can do for variable case). + if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, fields_arg) + { + builder.into_diagnostic(format_args!( + "Object of type `{}` is not valid as a `NamedTuple` field type", + field_type.display(db) + )); + } + } + + Some(Name::new(name.value(db))) + }) + .collect(); + + // Also validate AST elements if we have direct literals (for better error location). + if field_names.is_some() { + let elements: Option<&[ast::Expr]> = match fields_arg { + ast::Expr::List(list) => Some(&list.elts), + ast::Expr::Tuple(tuple) => Some(&tuple.elts), + _ => None, + }; + if let Some(elements) = elements { + for elt in elements { + // Check that each element is a valid (name, type) pair. + let field_spec_elts: &[ast::Expr] = match elt { + ast::Expr::Tuple(tuple) => &tuple.elts, + ast::Expr::List(list) => &list.elts, + _ => continue, + }; + if field_spec_elts.len() == 2 { + // Validate the type expression (will be re-inferred in deferred phase). + let field_type_expr = &field_spec_elts[1]; + + // Skip validation for string literals; they're valid string annotations + // that will be parsed as types in the deferred phase. + if !matches!(field_type_expr, ast::Expr::StringLiteral(_)) { + let field_value_ty = self.expression_type(field_type_expr); + if field_value_ty + .in_type_expression(db, self.scope(), self.typevar_binding_context) + .is_err() { - builder.into_diagnostic(format_args!( - "Object of type `{}` is not valid as a `NamedTuple` field type", - field_type.display(db) - )); + if let Some(builder) = self + .context + .report_lint(&INVALID_TYPE_FORM, field_type_expr) + { + builder.into_diagnostic(format_args!( + "Object of type `{}` is not valid as a `NamedTuple` field type", + field_value_ty.display(db) + )); + } } - error.fallback_type } - }; - Some((Name::new(name.value(self.db())), resolved_ty, None)) - }) - .collect(); + } + } + } + } - fields + field_names } /// Report diagnostics for invalid field names in a namedtuple definition. @@ -7063,16 +7146,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - /// Extract fields from a typing.NamedTuple fields argument by looking at the AST directly. - /// This handles list/tuple literals that contain (name, type) pairs. - #[expect(clippy::type_complexity)] - fn extract_typing_namedtuple_fields_from_ast( - &mut self, + /// Extract field names from a `typing.NamedTuple` fields argument by looking at the AST directly. + /// + /// Only extracts field names; types are computed lazily via deferred inference + /// to support recursive namedtuples and string annotations. + fn extract_typing_namedtuple_field_names_from_ast( + &self, fields_arg: &ast::Expr, - ) -> Option, Option>)]>> { + ) -> Option> { let db = self.db(); - let scope_id = self.scope(); - let typevar_binding_context = self.typevar_binding_context; // Get the elements from the list or tuple literal. let elements: &[ast::Expr] = match fields_arg { @@ -7081,7 +7163,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { _ => return None, }; - let fields: Option> = elements + let field_names: Option> = elements .iter() .map(|elt| { // Each element should be a tuple or list like ("field_name", type) or ["field_name", type]. @@ -7098,15 +7180,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let field_name_expr = &field_spec_elts[0]; let field_name_ty = self.expression_type(field_name_expr); let field_name_lit = field_name_ty.as_string_literal()?; - let field_name = Name::new(field_name_lit.value(db)); - // Second element: field type (infer as type expression). + // Second element: validate the type expression. String literals will be parsed in + // the deferred inference phase, so skip those here. let field_type_expr = &field_spec_elts[1]; - let field_value_ty = self.expression_type(field_type_expr); - let field_ty = field_value_ty - .in_type_expression(db, scope_id, typevar_binding_context) - .unwrap_or_else(|error| { - // Report diagnostic for invalid type expression. + if !matches!(field_type_expr, ast::Expr::StringLiteral(_)) { + let field_value_ty = self.expression_type(field_type_expr); + if field_value_ty + .in_type_expression(db, self.scope(), self.typevar_binding_context) + .is_err() + { if let Some(builder) = self .context .report_lint(&INVALID_TYPE_FORM, field_type_expr) @@ -7116,14 +7199,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { field_value_ty.display(db) )); } - error.fallback_type - }); + } + } - Some((field_name, field_ty, None)) + Some(Name::new(field_name_lit.value(db))) }) .collect(); - fields + field_names } /// Extract field names from a collections.namedtuple fields argument by looking at the AST directly. @@ -15582,38 +15665,3 @@ impl<'db, 'ast> AddBinding<'db, 'ast> { }) } } - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum NamedTupleKind { - Collections, - Typing, -} - -impl NamedTupleKind { - const fn is_collections(self) -> bool { - matches!(self, Self::Collections) - } - - const fn is_typing(self) -> bool { - matches!(self, Self::Typing) - } - - fn from_type<'db>(db: &'db dyn Db, ty: Type<'db>) -> Option { - match ty { - Type::SpecialForm(SpecialFormType::NamedTuple) => Some(NamedTupleKind::Typing), - Type::FunctionLiteral(function) => function - .is_known(db, KnownFunction::NamedTuple) - .then_some(NamedTupleKind::Collections), - _ => None, - } - } -} - -impl std::fmt::Display for NamedTupleKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - NamedTupleKind::Collections => "namedtuple", - NamedTupleKind::Typing => "NamedTuple", - }) - } -} From a70b5f3518a166eb4540df088d39fef7e61a939c Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 16 Jan 2026 11:48:10 -0500 Subject: [PATCH 2/4] Handle dangling --- .../resources/mdtest/named_tuple.md | 44 +++++-- crates/ty_python_semantic/src/types/class.rs | 118 ++++++++++++++++-- 2 files changed, 141 insertions(+), 21 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index b47abd0ab28cb4..5ee52e936bf672 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -169,7 +169,9 @@ reveal_type(p.y) # revealed: int ### Functional syntax with variable fields and string annotations -String annotations in variable fields are properly resolved: +String annotations in variable fields don't currently resolve (this is a known limitation). The +types are extracted from the inferred tuple type, but string literals don't get evaluated as type +expressions: ```py from typing import NamedTuple @@ -178,22 +180,24 @@ fields = [("value", "int"), ("label", "str")] Item = NamedTuple("Item", fields) item = Item(42, "test") -reveal_type(item.value) # revealed: int -reveal_type(item.label) # revealed: str +# TODO: These should resolve to `int` and `str`, but string annotations in variable fields +# aren't currently evaluated. +reveal_type(item.value) # revealed: Any +reveal_type(item.label) # revealed: Any ``` -Recursive string annotations also work when fields come from a variable: +When using non-string types in variable fields, they work correctly when using a tuple literal: ```py from typing import NamedTuple -tree_fields = [("value", int), ("left", "Tree | None"), ("right", "Tree | None")] +tree_fields = (("value", int), ("left", int | None), ("right", int | None)) Tree = NamedTuple("Tree", tree_fields) t = Tree(1, None, None) reveal_type(t.value) # revealed: int -reveal_type(t.left) # revealed: Tree | None -reveal_type(t.right) # revealed: Tree | None +reveal_type(t.left) # revealed: int | None +reveal_type(t.right) # revealed: int | None ``` ### Functional syntax as base class (dangling call) @@ -214,7 +218,8 @@ reveal_type(p.y) # revealed: int reveal_type(p.magnitude()) # revealed: int | float ``` -String annotations in dangling calls currently don't resolve properly (this is a known limitation): +String annotations in dangling calls work correctly for forward references to classes defined in the +same scope. This allows recursive types: ```py from typing import NamedTuple @@ -224,7 +229,21 @@ class Node(NamedTuple("Node", [("value", int), ("next", "Node | None")])): n = Node(1, None) reveal_type(n.value) # revealed: int -# TODO: This should be `Node | None`, but string annotations in dangling calls aren't resolved. +reveal_type(n.next) # revealed: Node | None +``` + +Note that the string annotation must reference a name that exists in scope. References to the +internal NamedTuple name (if different from the class name) won't work: + +```py +from typing import NamedTuple + +# The string "X" refers to the internal name, not "BadNode", so it won't resolve: +class BadNode(NamedTuple("X", [("value", int), ("next", "X | None")])): + pass + +n = BadNode(1, None) +reveal_type(n.value) # revealed: int reveal_type(n.next) # revealed: Unknown ``` @@ -1007,13 +1026,16 @@ reveal_type(Pair) # revealed: # error: [invalid-argument-type] reveal_type(Pair(1, 2)) # revealed: Pair +# TODO: The deferred inference for TypeVars doesn't bind them correctly, so we get +# `TypeVar | TypeVar` instead of `T@Pair`. This should be fixed. + # error: [invalid-argument-type] # error: [invalid-argument-type] -reveal_type(Pair(1, 2).first) # revealed: T@Pair +reveal_type(Pair(1, 2).first) # revealed: TypeVar | TypeVar # error: [invalid-argument-type] # error: [invalid-argument-type] -reveal_type(Pair(1, 2).second) # revealed: T@Pair +reveal_type(Pair(1, 2).second) # revealed: TypeVar | TypeVar ``` ## Attributes on `NamedTuple` diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index b58f7d1dfb7bfe..88c4ccb9b30713 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -53,8 +53,8 @@ use crate::types::{ use crate::{ Db, FxIndexMap, FxIndexSet, FxOrderSet, Program, place::{ - Definedness, LookupError, LookupResult, Place, PlaceAndQualifiers, Widening, - known_module_symbol, place_from_bindings, place_from_declarations, + ConsideredDefinitions, Definedness, LookupError, LookupResult, Place, PlaceAndQualifiers, + Widening, known_module_symbol, place_from_bindings, place_from_declarations, symbol, }, semantic_index::{ attribute_assignments, @@ -73,6 +73,7 @@ use itertools::{Either, Itertools as _}; use ruff_db::diagnostic::Span; use ruff_db::files::File; use ruff_db::parsed::{ParsedModuleRef, parsed_module}; +use ruff_db::source::source_text; use ruff_python_ast::name::Name; use ruff_python_ast::{self as ast, NodeIndex, PythonVersion}; use ruff_text_size::{Ranged, TextRange}; @@ -5810,9 +5811,18 @@ fn dynamic_namedtuple_fields<'db>( let field_ty = field_type_exprs .get(name.as_str()) .map(|type_expr| { - deferred_inference + let ty = deferred_inference .try_expression_type(*type_expr) - .unwrap_or_else(Type::unknown) + .unwrap_or_else(Type::unknown); + // For TypeVar instances, we need to bind them to the definition. + // Other types (class literals, string annotations) are already + // correctly resolved by deferred inference. + if let Type::KnownInstance(KnownInstanceType::TypeVar(_)) = ty { + ty.in_type_expression(db, scope, Some(definition)) + .unwrap_or_else(|error| error.fallback_type) + } else { + ty + } }) .unwrap_or_else(Type::unknown); // typing.NamedTuple functional form doesn't support defaults. @@ -5906,12 +5916,26 @@ fn dynamic_namedtuple_fields<'db>( .cloned() .map(|name| { let field_ty = if let Some(type_expr) = field_type_exprs.get(name.as_str()) { - // Get the inferred type from scope inference. - let value_ty = scope_inference.expression_type(*type_expr); - // Convert value type to type (e.g., class literal to instance). - value_ty - .in_type_expression(db, scope, None) - .unwrap_or_else(|error| error.fallback_type) + // Check if the type expression is a string literal. + if let ast::Expr::StringLiteral(string_expr) = type_expr { + // Try to resolve the string annotation. + resolve_string_annotation_type(db, scope, string_expr).unwrap_or_else( + || { + // Fall back to using the inferred type if resolution fails. + let value_ty = scope_inference.expression_type(*type_expr); + value_ty + .in_type_expression(db, scope, None) + .unwrap_or_else(|error| error.fallback_type) + }, + ) + } else { + // Get the inferred type from scope inference. + let value_ty = scope_inference.expression_type(*type_expr); + // Convert value type to type (e.g., class literal to instance). + value_ty + .in_type_expression(db, scope, None) + .unwrap_or_else(|error| error.fallback_type) + } } else { // No AST expression available, use inferred type from variable. field_types_from_inferred @@ -5926,6 +5950,80 @@ fn dynamic_namedtuple_fields<'db>( } } +/// Resolves a type from a string annotation in a dangling `NamedTuple` call context. +/// +/// This handles simple cases like `"ClassName"` and union types like `"ClassName | None"`. +/// More complex type expressions return `Unknown`. +fn resolve_string_annotation_type<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + string_expr: &ast::ExprStringLiteral, +) -> Option> { + let file = scope.file(db); + let source = source_text(db, file); + + // Only handle simple string literals (not f-strings, byte strings, etc.) + let string_literal = string_expr.as_single_part_string()?; + let prefix = string_literal.flags.prefix(); + if prefix.is_raw() { + return None; + } + + // Check for escape characters. + if &source[string_literal.content_range()] != string_literal.as_str() { + return None; + } + + // Parse the string as a type annotation. + let parsed = + ruff_python_parser::parse_string_annotation(source.as_str(), string_literal).ok()?; + + // Resolve the parsed expression to a type. + resolve_parsed_type_expr(db, scope, parsed.expr()) +} + +/// Resolves a parsed type expression to a type by looking up names in the scope. +/// +/// This handles simple names, `None`, and union types (via `|` operator). +fn resolve_parsed_type_expr<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + expr: &ast::Expr, +) -> Option> { + match expr { + ast::Expr::Name(name) => { + if name.id.as_str() == "None" { + return Some(Type::none(db)); + } + // Look up the name in the scope. + let place = symbol( + db, + scope, + name.id.as_str(), + ConsideredDefinitions::AllReachable, + ); + match place.place { + Place::Defined(defined) => Some( + defined + .ty + .in_type_expression(db, scope, None) + .unwrap_or_else(|error| error.fallback_type), + ), + Place::Undefined => None, + } + } + ast::Expr::BinOp(binop) if binop.op == ast::Operator::BitOr => { + // Handle union types: `X | Y` + let left = resolve_parsed_type_expr(db, scope, &binop.left)?; + let right = resolve_parsed_type_expr(db, scope, &binop.right)?; + Some(UnionType::from_elements(db, [left, right])) + } + ast::Expr::NoneLiteral(_) => Some(Type::none(db)), + // More complex expressions are not supported. + _ => None, + } +} + /// Performs member lookups over an MRO (Method Resolution Order). /// /// This struct encapsulates the shared logic for looking up class and instance From 6e40cf8a889520db6e1ce8f342f8f7a58705f19b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 16 Jan 2026 12:52:47 -0500 Subject: [PATCH 3/4] Allow anchors --- .../resources/mdtest/named_tuple.md | 12 +- crates/ty_python_semantic/src/types.rs | 5 +- crates/ty_python_semantic/src/types/class.rs | 688 ++++++++---------- crates/ty_python_semantic/src/types/infer.rs | 96 ++- .../src/types/infer/builder.rs | 90 ++- 5 files changed, 452 insertions(+), 439 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index 5ee52e936bf672..10e46985d56024 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -244,7 +244,8 @@ class BadNode(NamedTuple("X", [("value", int), ("next", "X | None")])): n = BadNode(1, None) reveal_type(n.value) # revealed: int -reveal_type(n.next) # revealed: Unknown +# X is not in scope, so it resolves to Unknown; None is correctly resolved +reveal_type(n.next) # revealed: Unknown | None ``` ### Functional syntax with variable name @@ -1007,6 +1008,8 @@ reveal_type(LegacyProperty[str].value.fget) # revealed: (self, /) -> str reveal_type(LegacyProperty("height", 3.4).value) # revealed: int | float ``` +### Functional syntax with generics + Generic namedtuples can also be defined using the functional syntax with type variables in the field types. We don't currently support this, but mypy does: @@ -1026,16 +1029,13 @@ reveal_type(Pair) # revealed: # error: [invalid-argument-type] reveal_type(Pair(1, 2)) # revealed: Pair -# TODO: The deferred inference for TypeVars doesn't bind them correctly, so we get -# `TypeVar | TypeVar` instead of `T@Pair`. This should be fixed. - # error: [invalid-argument-type] # error: [invalid-argument-type] -reveal_type(Pair(1, 2).first) # revealed: TypeVar | TypeVar +reveal_type(Pair(1, 2).first) # revealed: T@Pair # error: [invalid-argument-type] # error: [invalid-argument-type] -reveal_type(Pair(1, 2).second) # revealed: TypeVar | TypeVar +reveal_type(Pair(1, 2).second) # revealed: T@Pair ``` ## Attributes on `NamedTuple` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 0cafb153be3144..39dc4e1600f2be 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -30,8 +30,9 @@ pub(crate) use self::cyclic::{PairVisitor, TypeTransformer}; pub(crate) use self::diagnostic::register_lints; pub use self::diagnostic::{TypeCheckDiagnostics, UNDEFINED_REVEAL, UNRESOLVED_REFERENCE}; pub(crate) use self::infer::{ - TypeContext, infer_deferred_types, infer_definition_types, infer_expression_type, - infer_expression_types, infer_scope_types, static_expression_truthiness, + CallAnchor, TypeContext, infer_deferred_namedtuple_call_types, infer_deferred_types, + infer_definition_types, infer_expression_type, infer_expression_types, infer_scope_types, + static_expression_truthiness, }; pub use self::signatures::ParameterKind; pub(crate) use self::signatures::{CallableSignature, Signature}; diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 88c4ccb9b30713..e369cde77e878b 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1,14 +1,17 @@ use std::borrow::Cow; use std::cell::RefCell; +use std::collections::BTreeMap; use std::fmt::Write; use std::sync::{LazyLock, Mutex}; use super::{ BoundTypeVarInstance, MemberLookupPolicy, Mro, MroIterator, SpecialFormType, StaticMroError, SubclassOfType, Truthiness, Type, TypeQualifiers, class_base::ClassBase, - function::FunctionType, + function::FunctionType, infer_scope_types, +}; +use super::{ + CallAnchor, TypeVarVariance, infer_deferred_namedtuple_call_types, infer_deferred_types, }; -use super::{TypeVarVariance, infer_deferred_types}; use crate::place::{DefinedPlace, TypeOrigin}; use crate::semantic_index::definition::{Definition, DefinitionState}; use crate::semantic_index::scope::{NodeWithScopeKind, Scope, ScopeKind}; @@ -53,8 +56,8 @@ use crate::types::{ use crate::{ Db, FxIndexMap, FxIndexSet, FxOrderSet, Program, place::{ - ConsideredDefinitions, Definedness, LookupError, LookupResult, Place, PlaceAndQualifiers, - Widening, known_module_symbol, place_from_bindings, place_from_declarations, symbol, + Definedness, LookupError, LookupResult, Place, PlaceAndQualifiers, Widening, + known_module_symbol, place_from_bindings, place_from_declarations, }, semantic_index::{ attribute_assignments, @@ -73,7 +76,6 @@ use itertools::{Either, Itertools as _}; use ruff_db::diagnostic::Span; use ruff_db::files::File; use ruff_db::parsed::{ParsedModuleRef, parsed_module}; -use ruff_db::source::source_text; use ruff_python_ast::name::Name; use ruff_python_ast::{self as ast, NodeIndex, PythonVersion}; use ruff_text_size::{Ranged, TextRange}; @@ -4838,7 +4840,7 @@ pub struct DynamicClassLiteral<'db> { /// uniquely identifies this class and can be used to find the `type()` call. /// - `ScopeOffset`: The `type()` call is "dangling" (not assigned). The offset /// is relative to the enclosing scope's anchor node index. - pub anchor: DynamicClassAnchor<'db>, + pub anchor: CallAnchor<'db>, /// The class members from the namespace dict (third argument to `type()`). /// Each entry is a (name, type) pair extracted from the dict literal. @@ -4855,28 +4857,6 @@ pub struct DynamicClassLiteral<'db> { pub dataclass_params: Option>, } -/// Anchor for identifying a dynamic class literal. -/// -/// This enum provides stable identity for `DynamicClassLiteral`: -/// - For assigned calls, the `Definition` uniquely identifies the class. -/// - For dangling calls, a relative offset provides stable identity. -#[derive( - Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, salsa::Update, get_size2::GetSize, -)] -pub enum DynamicClassAnchor<'db> { - /// The `type()` call is assigned to a variable. - /// - /// The `Definition` uniquely identifies this class. The `type()` call expression - /// is the `value` of the assignment, so we can get its range from the definition. - Definition(Definition<'db>), - - /// The `type()` call is "dangling" (not assigned to a variable). - /// - /// The offset is relative to the enclosing scope's anchor node index. - /// For module scope, this is equivalent to an absolute index (anchor is 0). - ScopeOffset { scope: ScopeId<'db>, offset: u32 }, -} - impl get_size2::GetSize for DynamicClassLiteral<'_> {} #[salsa::tracked] @@ -4884,16 +4864,16 @@ impl<'db> DynamicClassLiteral<'db> { /// Returns the definition where this class is created, if it was assigned to a variable. pub(crate) fn definition(self, db: &'db dyn Db) -> Option> { match self.anchor(db) { - DynamicClassAnchor::Definition(definition) => Some(definition), - DynamicClassAnchor::ScopeOffset { .. } => None, + CallAnchor::Definition(definition) => Some(definition), + CallAnchor::ScopeOffset { .. } => None, } } /// Returns the scope in which this dynamic class was created. pub(crate) fn scope(self, db: &'db dyn Db) -> ScopeId<'db> { match self.anchor(db) { - DynamicClassAnchor::Definition(definition) => definition.scope(db), - DynamicClassAnchor::ScopeOffset { scope, .. } => scope, + CallAnchor::Definition(definition) => definition.scope(db), + CallAnchor::ScopeOffset { scope, .. } => scope, } } @@ -4911,16 +4891,16 @@ impl<'db> DynamicClassLiteral<'db> { let module = parsed_module(db, file).load(db); match self.anchor(db) { - DynamicClassAnchor::Definition(definition) => { + CallAnchor::Definition(definition) => { // For definitions, get the range from the definition's value. // The `type()` call is the value of the assignment. definition .kind(db) .value(&module) - .expect("DynamicClassAnchor::Definition should only be used for assignments") + .expect("CallAnchor::Definition should only be used for assignments") .range() } - DynamicClassAnchor::ScopeOffset { offset, .. } => { + CallAnchor::ScopeOffset { offset, .. } => { // For dangling `type()` calls, compute the absolute index from the offset. let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); let anchor_u32 = scope_anchor @@ -5358,7 +5338,7 @@ pub struct DynamicNamedTupleLiteral<'db> { /// uniquely identifies this namedtuple and can be used to find the call. /// - `ScopeOffset`: The call is "dangling" (not assigned). The offset /// is relative to the enclosing scope's anchor node index. - pub anchor: DynamicClassAnchor<'db>, + pub anchor: CallAnchor<'db>, } impl get_size2::GetSize for DynamicNamedTupleLiteral<'_> {} @@ -5368,16 +5348,16 @@ impl<'db> DynamicNamedTupleLiteral<'db> { /// Returns the definition where this namedtuple is created, if it was assigned to a variable. pub(crate) fn definition(self, db: &'db dyn Db) -> Option> { match self.anchor(db) { - DynamicClassAnchor::Definition(definition) => Some(definition), - DynamicClassAnchor::ScopeOffset { .. } => None, + CallAnchor::Definition(definition) => Some(definition), + CallAnchor::ScopeOffset { .. } => None, } } /// Returns the scope in which this dynamic class was created. pub(crate) fn scope(self, db: &'db dyn Db) -> ScopeId<'db> { match self.anchor(db) { - DynamicClassAnchor::Definition(definition) => definition.scope(db), - DynamicClassAnchor::ScopeOffset { scope, .. } => scope, + CallAnchor::Definition(definition) => definition.scope(db), + CallAnchor::ScopeOffset { scope, .. } => scope, } } @@ -5393,16 +5373,16 @@ impl<'db> DynamicNamedTupleLiteral<'db> { let module = parsed_module(db, file).load(db); match self.anchor(db) { - DynamicClassAnchor::Definition(definition) => { + CallAnchor::Definition(definition) => { // For definitions, get the range from the definition's value. // The namedtuple call is the value of the assignment. definition .kind(db) .value(&module) - .expect("DynamicClassAnchor::Definition should only be used for assignments") + .expect("CallAnchor::Definition should only be used for assignments") .range() } - DynamicClassAnchor::ScopeOffset { offset, .. } => { + CallAnchor::ScopeOffset { offset, .. } => { // For dangling calls, compute the absolute index from the offset. let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); let anchor_u32 = scope_anchor @@ -5434,7 +5414,276 @@ impl<'db> DynamicNamedTupleLiteral<'db> { /// fields with defaults (only for `collections.namedtuple` via the `defaults` parameter). #[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] pub(crate) fn fields(self, db: &'db dyn Db) -> Box<[(Name, Type<'db>, Option>)]> { - dynamic_namedtuple_fields(db, self) + let raw_fields = self.raw_fields(db); + let kind = self.kind(db); + let default_types = self.default_types(db); + let num_defaults = default_types.len(); + let num_fields = raw_fields.len(); + + // For `collections.namedtuple`, all types are `Any`. + if kind == NamedTupleKind::Collections { + return raw_fields + .iter() + .enumerate() + .map(|(i, name)| { + let default_ty = if num_defaults > 0 && i >= num_fields - num_defaults { + // Get the actual default type from the stored defaults. + let default_index = i - (num_fields - num_defaults); + Some(default_types[default_index]) + } else { + None + }; + (name.clone(), Type::any(), default_ty) + }) + .collect(); + } + + // For `typing.NamedTuple`, compute field types. + let scope = self.scope(db); + let file = scope.file(db); + let module = parsed_module(db, file).load(db); + + // Get the call expression, either from the definition or from the scope offset. + let call_expr: &ast::ExprCall = match self.anchor(db) { + CallAnchor::Definition(definition) => definition + .kind(db) + .value(&module) + .expect("NamedTuple definition should have a value") + .as_call_expr() + .expect("NamedTuple definition value should be a Call"), + CallAnchor::ScopeOffset { offset, .. } => { + let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); + let anchor_u32 = scope_anchor + .as_u32() + .expect("anchor should not be NodeIndex::NONE"); + let absolute_index = NodeIndex::from(anchor_u32 + offset); + module + .get_by_index(absolute_index) + .try_into() + .expect("scope offset should point to ExprCall") + } + }; + + // The second positional argument should be the fields list/tuple. + let Some(fields_arg) = call_expr.arguments.args.get(1) else { + return raw_fields + .iter() + .cloned() + .map(|name| (name, Type::unknown(), None)) + .collect(); + }; + + // Try to get field type expressions from AST literal. + // If the fields argument is not a literal (e.g., a variable), we'll fall back to + // extracting types from the inferred type. + let elements: Option<&[ast::Expr]> = match fields_arg { + ast::Expr::List(list) => Some(&list.elts), + ast::Expr::Tuple(tuple) => Some(&tuple.elts), + _ => None, + }; + + // Build a mapping from field names to their type expressions (for AST literals). + let mut field_type_exprs = BTreeMap::<&str, &ast::Expr>::new(); + if let Some(elements) = elements { + for elt in elements { + // Each element should be a tuple like ("field_name", type). + let field_spec_elts: &[ast::Expr] = match elt { + ast::Expr::Tuple(tuple) => &tuple.elts, + ast::Expr::List(list) => &list.elts, + _ => continue, + }; + let [field_name_expr, field_type_expr] = &field_spec_elts else { + continue; + }; + + // First element: field name (should be a string literal). + if let ast::Expr::StringLiteral(string_lit) = field_name_expr { + let field_name = string_lit.value.to_str(); + field_type_exprs.insert(field_name, field_type_expr); + } + } + } + + // Build fields by looking up types. + // For definitions with AST literal fields, use deferred inference to support recursion. + // For dangling calls or variable fields, use scope inference. + match self.anchor(db) { + CallAnchor::Definition(definition) => { + // Check if all fields have AST type expressions. + let all_fields_have_ast = raw_fields + .iter() + .all(|name| field_type_exprs.contains_key(name.as_str())); + + if all_fields_have_ast { + let deferred_inference = infer_deferred_types(db, definition); + return raw_fields + .iter() + .cloned() + .map(|name| { + let field_ty = field_type_exprs + .get(name.as_str()) + .map(|type_expr| { + let ty = deferred_inference + .try_expression_type(*type_expr) + .unwrap_or_else(Type::unknown); + // For TypeVar instances, we need to bind them to the definition. + // Other types (class literals, string annotations) are already + // correctly resolved by deferred inference. + if let Type::KnownInstance(KnownInstanceType::TypeVar(_)) = ty { + ty.in_type_expression(db, scope, Some(definition)) + .unwrap_or_else(|error| error.fallback_type) + } else { + ty + } + }) + .unwrap_or_else(Type::unknown); + // `typing.NamedTuple` functional form doesn't support defaults. + (name, field_ty, None) + }) + .collect(); + } + + // Some fields come from variables; we need scope inference for those. + let scope_inference = infer_scope_types(db, scope); + let fields_type = scope_inference.expression_type(fields_arg); + + // Build a mapping from field names to their types (from the inferred type). + let mut field_types_from_inferred = BTreeMap::<&str, Type<'db>>::new(); + if let Some(tuple_spec) = fields_type.tuple_instance_spec(db) + && let Some(fixed_tuple) = tuple_spec.as_fixed_length() + { + for field_tuple in fixed_tuple.all_elements() { + // Each field must be a fixed-length tuple of exactly 2 elements. + if let Some(field_spec) = field_tuple.exact_tuple_instance_spec(db) + && let Some(field_fixed) = field_spec.as_fixed_length() + && let [Type::StringLiteral(name_lit), field_type] = + field_fixed.all_elements() + { + let field_name = name_lit.value(db); + // Convert value type to type expression. + let field_ty = field_type + .in_type_expression(db, scope, None) + .unwrap_or_else(|error| error.fallback_type); + field_types_from_inferred.insert(field_name, field_ty); + } + } + } + + let deferred_inference = infer_deferred_types(db, definition); + raw_fields + .iter() + .cloned() + .map(|name| { + let field_ty = if let Some(type_expr) = field_type_exprs.get(name.as_str()) + { + // Get the inferred type from deferred inference. + deferred_inference + .try_expression_type(*type_expr) + .unwrap_or_else(|| { + // Fall back to inferred type from variable. + field_types_from_inferred + .get(name.as_str()) + .copied() + .unwrap_or_else(Type::unknown) + }) + } else { + // No AST expression available, use inferred type from variable. + field_types_from_inferred + .get(name.as_str()) + .copied() + .unwrap_or_else(Type::unknown) + }; + // `typing.NamedTuple` functional form doesn't support defaults. + (name, field_ty, None) + }) + .collect() + } + + CallAnchor::ScopeOffset { offset, .. } => { + // Check if all fields have AST type expressions. + let all_fields_have_ast = raw_fields + .iter() + .all(|name| field_type_exprs.contains_key(name.as_str())); + + if all_fields_have_ast { + // Use deferred inference to support forward references in string annotations. + let deferred_inference = + infer_deferred_namedtuple_call_types(db, scope, offset); + return raw_fields + .iter() + .cloned() + .map(|name| { + let field_ty = field_type_exprs + .get(name.as_str()) + .map(|type_expr| { + deferred_inference + .try_expression_type(*type_expr) + .unwrap_or_else(Type::unknown) + }) + .unwrap_or_else(Type::unknown); + // `typing.NamedTuple` functional form doesn't support defaults. + (name, field_ty, None) + }) + .collect(); + } + + // Some fields come from variables; we need scope inference for those. + let scope_inference = infer_scope_types(db, scope); + let fields_type = scope_inference.expression_type(fields_arg); + + // Build a mapping from field names to their types (from the inferred type). + let mut field_types_from_inferred = BTreeMap::<&str, Type<'db>>::new(); + if let Some(tuple_spec) = fields_type.tuple_instance_spec(db) + && let Some(fixed_tuple) = tuple_spec.as_fixed_length() + { + for field_tuple in fixed_tuple.all_elements() { + // Each field must be a fixed-length tuple of exactly 2 elements. + if let Some(field_spec) = field_tuple.exact_tuple_instance_spec(db) + && let Some(field_fixed) = field_spec.as_fixed_length() + && let [Type::StringLiteral(name_lit), field_type] = + field_fixed.all_elements() + { + let field_name = name_lit.value(db); + // Convert value type to type expression. + let field_ty = field_type + .in_type_expression(db, scope, None) + .unwrap_or_else(|error| error.fallback_type); + field_types_from_inferred.insert(field_name, field_ty); + } + } + } + + // Use deferred inference for AST literal fields, scope inference for variable fields. + let deferred_inference = infer_deferred_namedtuple_call_types(db, scope, offset); + raw_fields + .iter() + .cloned() + .map(|name| { + let field_ty = if let Some(type_expr) = field_type_exprs.get(name.as_str()) + { + // Get the inferred type from deferred inference. + deferred_inference + .try_expression_type(*type_expr) + .unwrap_or_else(|| { + // Fall back to inferred type from variable. + field_types_from_inferred + .get(name.as_str()) + .copied() + .unwrap_or_else(Type::unknown) + }) + } else { + // No AST expression available, use inferred type from variable. + field_types_from_inferred + .get(name.as_str()) + .copied() + .unwrap_or_else(Type::unknown) + }; + // `typing.NamedTuple` functional form doesn't support defaults. + (name, field_ty, None) + }) + .collect() + } + } } /// Compute the MRO for this namedtuple. @@ -5673,357 +5922,6 @@ impl<'db> DynamicNamedTupleLiteral<'db> { } } -/// Compute field types for a dynamic namedtuple. -/// -/// For `typing.NamedTuple`, field types are computed using deferred inference to support -/// recursive namedtuples and proper string annotation handling. -/// For `collections.namedtuple`, all field types are `Any`. -fn dynamic_namedtuple_fields<'db>( - db: &'db dyn Db, - namedtuple: DynamicNamedTupleLiteral<'db>, -) -> Box<[(Name, Type<'db>, Option>)]> { - use crate::types::infer::infer_scope_types; - - let raw_fields = namedtuple.raw_fields(db); - let kind = namedtuple.kind(db); - let default_types = namedtuple.default_types(db); - let num_defaults = default_types.len(); - let num_fields = raw_fields.len(); - - // For collections.namedtuple, all types are Any. - if kind == NamedTupleKind::Collections { - return raw_fields - .iter() - .enumerate() - .map(|(i, name)| { - let default_ty = if num_defaults > 0 && i >= num_fields - num_defaults { - // Get the actual default type from the stored defaults. - let default_index = i - (num_fields - num_defaults); - Some(default_types[default_index]) - } else { - None - }; - (name.clone(), Type::any(), default_ty) - }) - .collect(); - } - - // For typing.NamedTuple, compute field types. - // For assigned calls (with definition), use deferred inference for recursive support. - // For dangling calls (without definition), use regular expression inference. - - let scope = namedtuple.scope(db); - let file = scope.file(db); - let module = parsed_module(db, file).load(db); - - // Get the call expression, either from the definition or from the scope offset. - let call_expr: &ast::ExprCall = match namedtuple.anchor(db) { - DynamicClassAnchor::Definition(definition) => { - let Some(expr) = definition.kind(db).value(&module) else { - return raw_fields - .iter() - .cloned() - .map(|name| (name, Type::unknown(), None)) - .collect(); - }; - match expr { - ast::Expr::Call(call) => call, - _ => { - return raw_fields - .iter() - .cloned() - .map(|name| (name, Type::unknown(), None)) - .collect(); - } - } - } - DynamicClassAnchor::ScopeOffset { offset, .. } => { - let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); - let anchor_u32 = scope_anchor - .as_u32() - .expect("anchor should not be NodeIndex::NONE"); - let absolute_index = NodeIndex::from(anchor_u32 + offset); - module - .get_by_index(absolute_index) - .try_into() - .expect("scope offset should point to ExprCall") - } - }; - - // The second positional argument should be the fields list/tuple. - let Some(fields_arg) = call_expr.arguments.args.get(1) else { - return raw_fields - .iter() - .cloned() - .map(|name| (name, Type::unknown(), None)) - .collect(); - }; - - // Try to get field type expressions from AST literal. - // If the fields argument is not a literal (e.g., a variable), we'll fall back to - // extracting types from the inferred type. - let elements: Option<&[ast::Expr]> = match fields_arg { - ast::Expr::List(list) => Some(&list.elts), - ast::Expr::Tuple(tuple) => Some(&tuple.elts), - _ => None, - }; - - // Build a mapping from field names to their type expressions (for AST literals). - let mut field_type_exprs: std::collections::BTreeMap<&str, &ast::Expr> = - std::collections::BTreeMap::new(); - if let Some(elements) = elements { - for elt in elements { - // Each element should be a tuple like ("field_name", type). - let field_spec_elts: &[ast::Expr] = match elt { - ast::Expr::Tuple(tuple) => &tuple.elts, - ast::Expr::List(list) => &list.elts, - _ => continue, - }; - if field_spec_elts.len() != 2 { - continue; - } - - // First element: field name (should be a string literal). - let field_name_expr = &field_spec_elts[0]; - if let ast::Expr::StringLiteral(string_lit) = field_name_expr { - let field_name = string_lit.value.to_str(); - let field_type_expr = &field_spec_elts[1]; - field_type_exprs.insert(field_name, field_type_expr); - } - } - } - - // Build fields by looking up types. - // For definitions with AST literal fields, use deferred inference to support recursion. - // For dangling calls or variable fields, use scope inference. - if let Some(definition) = namedtuple.definition(db) { - // Check if all fields have AST type expressions. - let all_fields_have_ast = raw_fields - .iter() - .all(|name| field_type_exprs.contains_key(name.as_str())); - - if all_fields_have_ast { - let deferred_inference = infer_deferred_types(db, definition); - return raw_fields - .iter() - .cloned() - .map(|name| { - let field_ty = field_type_exprs - .get(name.as_str()) - .map(|type_expr| { - let ty = deferred_inference - .try_expression_type(*type_expr) - .unwrap_or_else(Type::unknown); - // For TypeVar instances, we need to bind them to the definition. - // Other types (class literals, string annotations) are already - // correctly resolved by deferred inference. - if let Type::KnownInstance(KnownInstanceType::TypeVar(_)) = ty { - ty.in_type_expression(db, scope, Some(definition)) - .unwrap_or_else(|error| error.fallback_type) - } else { - ty - } - }) - .unwrap_or_else(Type::unknown); - // typing.NamedTuple functional form doesn't support defaults. - (name, field_ty, None) - }) - .collect(); - } - - // Some fields come from variables; we need scope inference for those. - let scope_inference = infer_scope_types(db, scope); - let fields_type = scope_inference.expression_type(fields_arg); - - // Build a mapping from field names to their types (from the inferred type). - let mut field_types_from_inferred: std::collections::BTreeMap<&str, Type<'db>> = - std::collections::BTreeMap::new(); - if let Some(tuple_spec) = fields_type.tuple_instance_spec(db) - && let Some(fixed_tuple) = tuple_spec.as_fixed_length() - { - for field_tuple in fixed_tuple.all_elements() { - // Each field must be a fixed-length tuple of exactly 2 elements. - if let Some(field_spec) = field_tuple.exact_tuple_instance_spec(db) - && let Some(field_fixed) = field_spec.as_fixed_length() - && let [Type::StringLiteral(name_lit), field_type] = field_fixed.all_elements() - { - let field_name = name_lit.value(db); - // Convert value type to type expression. - let field_ty = field_type - .in_type_expression(db, scope, None) - .unwrap_or_else(|error| error.fallback_type); - field_types_from_inferred.insert(field_name, field_ty); - } - } - } - - let deferred_inference = infer_deferred_types(db, definition); - raw_fields - .iter() - .cloned() - .map(|name| { - let field_ty = if let Some(type_expr) = field_type_exprs.get(name.as_str()) { - // Get the inferred type from deferred inference. - deferred_inference - .try_expression_type(*type_expr) - .unwrap_or_else(|| { - // Fall back to inferred type from variable. - field_types_from_inferred - .get(name.as_str()) - .copied() - .unwrap_or_else(Type::unknown) - }) - } else { - // No AST expression available, use inferred type from variable. - field_types_from_inferred - .get(name.as_str()) - .copied() - .unwrap_or_else(Type::unknown) - }; - // typing.NamedTuple functional form doesn't support defaults. - (name, field_ty, None) - }) - .collect() - } else { - // For dangling calls (e.g., used as base class), use scope inference. - let scope_inference = infer_scope_types(db, scope); - let fields_type = scope_inference.expression_type(fields_arg); - - // Build a mapping from field names to their types (from the inferred type). - let mut field_types_from_inferred: std::collections::BTreeMap<&str, Type<'db>> = - std::collections::BTreeMap::new(); - if let Some(tuple_spec) = fields_type.tuple_instance_spec(db) - && let Some(fixed_tuple) = tuple_spec.as_fixed_length() - { - for field_tuple in fixed_tuple.all_elements() { - // Each field must be a fixed-length tuple of exactly 2 elements. - if let Some(field_spec) = field_tuple.exact_tuple_instance_spec(db) - && let Some(field_fixed) = field_spec.as_fixed_length() - && let [Type::StringLiteral(name_lit), field_type] = field_fixed.all_elements() - { - let field_name = name_lit.value(db); - // Convert value type to type expression. - let field_ty = field_type - .in_type_expression(db, scope, None) - .unwrap_or_else(|error| error.fallback_type); - field_types_from_inferred.insert(field_name, field_ty); - } - } - } - - raw_fields - .iter() - .cloned() - .map(|name| { - let field_ty = if let Some(type_expr) = field_type_exprs.get(name.as_str()) { - // Check if the type expression is a string literal. - if let ast::Expr::StringLiteral(string_expr) = type_expr { - // Try to resolve the string annotation. - resolve_string_annotation_type(db, scope, string_expr).unwrap_or_else( - || { - // Fall back to using the inferred type if resolution fails. - let value_ty = scope_inference.expression_type(*type_expr); - value_ty - .in_type_expression(db, scope, None) - .unwrap_or_else(|error| error.fallback_type) - }, - ) - } else { - // Get the inferred type from scope inference. - let value_ty = scope_inference.expression_type(*type_expr); - // Convert value type to type (e.g., class literal to instance). - value_ty - .in_type_expression(db, scope, None) - .unwrap_or_else(|error| error.fallback_type) - } - } else { - // No AST expression available, use inferred type from variable. - field_types_from_inferred - .get(name.as_str()) - .copied() - .unwrap_or_else(Type::unknown) - }; - // typing.NamedTuple functional form doesn't support defaults. - (name, field_ty, None) - }) - .collect() - } -} - -/// Resolves a type from a string annotation in a dangling `NamedTuple` call context. -/// -/// This handles simple cases like `"ClassName"` and union types like `"ClassName | None"`. -/// More complex type expressions return `Unknown`. -fn resolve_string_annotation_type<'db>( - db: &'db dyn Db, - scope: ScopeId<'db>, - string_expr: &ast::ExprStringLiteral, -) -> Option> { - let file = scope.file(db); - let source = source_text(db, file); - - // Only handle simple string literals (not f-strings, byte strings, etc.) - let string_literal = string_expr.as_single_part_string()?; - let prefix = string_literal.flags.prefix(); - if prefix.is_raw() { - return None; - } - - // Check for escape characters. - if &source[string_literal.content_range()] != string_literal.as_str() { - return None; - } - - // Parse the string as a type annotation. - let parsed = - ruff_python_parser::parse_string_annotation(source.as_str(), string_literal).ok()?; - - // Resolve the parsed expression to a type. - resolve_parsed_type_expr(db, scope, parsed.expr()) -} - -/// Resolves a parsed type expression to a type by looking up names in the scope. -/// -/// This handles simple names, `None`, and union types (via `|` operator). -fn resolve_parsed_type_expr<'db>( - db: &'db dyn Db, - scope: ScopeId<'db>, - expr: &ast::Expr, -) -> Option> { - match expr { - ast::Expr::Name(name) => { - if name.id.as_str() == "None" { - return Some(Type::none(db)); - } - // Look up the name in the scope. - let place = symbol( - db, - scope, - name.id.as_str(), - ConsideredDefinitions::AllReachable, - ); - match place.place { - Place::Defined(defined) => Some( - defined - .ty - .in_type_expression(db, scope, None) - .unwrap_or_else(|error| error.fallback_type), - ), - Place::Undefined => None, - } - } - ast::Expr::BinOp(binop) if binop.op == ast::Operator::BitOr => { - // Handle union types: `X | Y` - let left = resolve_parsed_type_expr(db, scope, &binop.left)?; - let right = resolve_parsed_type_expr(db, scope, &binop.right)?; - Some(UnionType::from_elements(db, [left, right])) - } - ast::Expr::NoneLiteral(_) => Some(Type::none(db)), - // More complex expressions are not supported. - _ => None, - } -} - /// Performs member lookups over an MRO (Method Resolution Order). /// /// This struct encapsulates the shared logic for looking up class and instance diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index f437f128e581d3..a1796fd55e74a3 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -160,8 +160,13 @@ pub(crate) fn infer_deferred_types<'db>( let index = semantic_index(db, file); - TypeInferenceBuilder::new(db, InferenceRegion::Deferred(definition), index, &module) - .finish_definition() + TypeInferenceBuilder::new( + db, + InferenceRegion::Deferred(CallAnchor::Definition(definition)), + index, + &module, + ) + .finish_definition() } fn deferred_cycle_recovery<'db>( @@ -182,6 +187,37 @@ fn deferred_cycle_initial<'db>( DefinitionInference::cycle_initial(definition.scope(db), Type::divergent(id)) } +/// Infer deferred field types for a dangling `NamedTuple` call. +/// +/// This is used when a `NamedTuple` call is used directly as a base class without being +/// assigned to a variable first. The offset is relative to the scope's anchor node index. +#[salsa::tracked(returns(ref), heap_size=ruff_memory_usage::heap_size)] +pub(crate) fn infer_deferred_namedtuple_call_types<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + offset: u32, +) -> ExpressionInference<'db> { + let file = scope.file(db); + let module = parsed_module(db, file).load(db); + let _span = tracing::trace_span!( + "infer_deferred_namedtuple_call_types", + scope = ?scope.as_id(), + offset, + ?file + ) + .entered(); + + let index = semantic_index(db, file); + + TypeInferenceBuilder::new( + db, + InferenceRegion::Deferred(CallAnchor::ScopeOffset { scope, offset }), + index, + &module, + ) + .finish_expression() +} + /// Infer all types for an [`Expression`] (including sub-expressions). /// Use rarely; only for cases where we'd otherwise risk double-inferring an expression: RHS of an /// assignment, which might be unpacking/multi-target and thus part of multiple definitions, or a @@ -503,16 +539,57 @@ pub(crate) fn nearest_enclosing_function<'db>( }) } +/// Identifies a call expression for deferred type inference. +/// +/// This is used to locate call expressions (like `NamedTuple(...)`, `TypedDict(...)`, +/// or `type(...)`) that may contain string annotations requiring deferred resolution. +/// +/// There are two cases: +/// - For assigned calls (e.g., `Point = NamedTuple(...)`), the `Definition` uniquely +/// identifies the call and we can get the expression from the definition's value. +/// - For dangling calls (e.g., used directly as base classes), we store a relative +/// offset from the enclosing scope's anchor node index to locate the call. +#[derive( + Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, salsa::Update, get_size2::GetSize, +)] +pub enum CallAnchor<'db> { + /// The call is assigned to a variable. + /// + /// The `Definition` uniquely identifies this call. The call expression + /// is the `value` of the assignment, so we can get its range from the definition. + Definition(Definition<'db>), + + /// The call is "dangling" (not assigned to a variable). + /// + /// The offset is relative to the enclosing scope's anchor node index. + /// For module scope, this is equivalent to an absolute index (anchor is 0). + ScopeOffset { scope: ScopeId<'db>, offset: u32 }, +} + +impl<'db> CallAnchor<'db> { + /// Returns the scope in which this call exists. + pub(crate) fn scope(self, db: &'db dyn Db) -> ScopeId<'db> { + match self { + CallAnchor::Definition(definition) => definition.scope(db), + CallAnchor::ScopeOffset { scope, .. } => scope, + } + } +} + /// A region within which we can infer types. #[derive(Copy, Clone, Debug)] pub(crate) enum InferenceRegion<'db> { - /// infer types for a standalone [`Expression`] + /// Infer types for a standalone [`Expression`] Expression(Expression<'db>, TypeContext<'db>), - /// infer types for a [`Definition`] + /// Infer types for a [`Definition`] Definition(Definition<'db>), - /// infer deferred types for a [`Definition`] - Deferred(Definition<'db>), - /// infer types for an entire [`ScopeId`] + /// Infer deferred types for a dynamic class call (`NamedTuple`, `TypedDict`, etc.) + /// + /// The anchor identifies the call - either by its definition (for assigned calls like + /// `Point = NamedTuple(...)`) or by scope offset (for dangling calls used directly + /// as base classes). + Deferred(CallAnchor<'db>), + /// Infer types for an entire [`ScopeId`] Scope(ScopeId<'db>), } @@ -520,9 +597,8 @@ impl<'db> InferenceRegion<'db> { fn scope(self, db: &'db dyn Db) -> ScopeId<'db> { match self { InferenceRegion::Expression(expression, _) => expression.scope(db), - InferenceRegion::Definition(definition) | InferenceRegion::Deferred(definition) => { - definition.scope(db) - } + InferenceRegion::Definition(definition) => definition.scope(db), + InferenceRegion::Deferred(anchor) => anchor.scope(db), InferenceRegion::Scope(scope) => scope, } } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 0cbe38bbecc222..8c57efda991145 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -22,6 +22,7 @@ use ty_module_resolver::{ resolve_module, search_paths, }; +use super::CallAnchor; use super::{ DefinitionInference, DefinitionInferenceExtra, ExpressionInference, ExpressionInferenceExtra, InferenceRegion, ScopeInference, ScopeInferenceExtra, infer_deferred_types, @@ -58,8 +59,8 @@ use crate::subscript::{PyIndex, PySlice}; use crate::types::call::bind::{CallableDescription, MatchingOverloadIndex}; use crate::types::call::{Argument, Binding, Bindings, CallArguments, CallError, CallErrorKind}; use crate::types::class::{ - ClassLiteral, CodeGeneratorKind, DynamicClassAnchor, DynamicClassLiteral, - DynamicMetaclassConflict, FieldKind, MetaclassErrorKind, MethodDecorator, + ClassLiteral, CodeGeneratorKind, DynamicClassLiteral, DynamicMetaclassConflict, FieldKind, + MetaclassErrorKind, MethodDecorator, }; use crate::types::class::{DynamicNamedTupleLiteral, NamedTupleKind}; use crate::types::context::{InNoTypeCheck, InferContext}; @@ -536,7 +537,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { match self.region { InferenceRegion::Scope(scope) => self.infer_region_scope(scope), InferenceRegion::Definition(definition) => self.infer_region_definition(definition), - InferenceRegion::Deferred(definition) => self.infer_region_deferred(definition), + InferenceRegion::Deferred(anchor) => self.infer_region_deferred(anchor), InferenceRegion::Expression(expression, tcx) => { self.infer_region_expression(expression, tcx); } @@ -1653,7 +1654,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - fn infer_region_deferred(&mut self, definition: Definition<'db>) { + fn infer_region_deferred(&mut self, anchor: CallAnchor<'db>) { + match anchor { + CallAnchor::Definition(definition) => { + self.infer_region_deferred_definition(definition); + } + CallAnchor::ScopeOffset { scope, offset } => { + self.infer_region_deferred_scope_offset(scope, offset); + } + } + } + + fn infer_region_deferred_definition(&mut self, definition: Definition<'db>) { // N.B. We don't defer the types for an annotated assignment here because it is done in // the same definition query. It utilizes the deferred expression state instead. // @@ -1683,6 +1695,31 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } + /// Infer deferred field types for a dangling call (e.g., `NamedTuple`, `TypedDict`). + /// + /// This looks up the call expression using the scope offset and infers the field type + /// annotations with deferred state, enabling forward references. + fn infer_region_deferred_scope_offset(&mut self, scope: ScopeId<'db>, offset: u32) { + let db = self.db(); + + // Compute the absolute node index from the scope anchor + offset. + let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); + let anchor_u32 = scope_anchor + .as_u32() + .expect("scope anchor should not be NodeIndex::NONE"); + let absolute_index = NodeIndex::from(anchor_u32 + offset); + + // Look up the call expression from the parsed module. + let call_expr: &ast::ExprCall = self + .module() + .get_by_index(absolute_index) + .try_into() + .expect("offset should point to ExprCall"); + + // Infer the field type annotations with deferred state. + self.infer_functional_namedtuple_deferred(&call_expr.arguments); + } + fn infer_region_expression(&mut self, expression: Expression<'db>, tcx: TypeContext<'db>) { match expression.kind(self.db()) { ExpressionKind::Normal => { @@ -6418,7 +6455,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // - For assigned `type()` calls, the Definition uniquely identifies the class. // - For dangling calls, compute a relative offset from the scope's node index. let anchor = if let Some(def) = definition { - DynamicClassAnchor::Definition(def) + CallAnchor::Definition(def) } else { let call_node_index = call_expr.node_index().load(); let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); @@ -6428,7 +6465,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let call_u32 = call_node_index .as_u32() .expect("call node should not be NodeIndex::NONE"); - DynamicClassAnchor::ScopeOffset { + CallAnchor::ScopeOffset { scope, offset: call_u32 - anchor_u32, } @@ -6782,9 +6819,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }; // Handle fields based on which namedtuple variant. - // For typing.NamedTuple, we only extract field names here; types are computed lazily + // + // For `typing.NamedTuple`, we only extract field names here; types are computed lazily // via deferred inference to support recursive namedtuples and string annotations. - // For collections.namedtuple, all types are Any, so we just need names and default types. + // + // For `collections.namedtuple`, all types are `Any`, so we just need names and default + // types. let (raw_fields, field_defaults, has_known_fields): (Box<[Name]>, Box<[Type<'db>]>, bool) = match kind { NamedTupleKind::Typing => { @@ -6829,10 +6869,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { for elt in elements { let is_valid_field_spec = matches!( elt, - ast::Expr::Tuple(t) if t.elts.len() == 2 + ast::Expr::Tuple(tuple) if tuple.elts.len() == 2 ) || matches!( elt, - ast::Expr::List(l) if l.elts.len() == 2 + ast::Expr::List(list) if list.elts.len() == 2 ); if !is_valid_field_spec { let elt_type = self.expression_type(elt); @@ -6855,7 +6895,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } let has_known_fields = field_names.is_some(); - // typing.NamedTuple functional form doesn't support defaults. + // The `typing.NamedTuple` functional form doesn't support defaults. ( field_names.unwrap_or_default(), Box::new([]), @@ -6961,12 +7001,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { diagnostic.info("This will raise `TypeError` at runtime"); } - // Truncate defaults to at most num_fields (extra defaults are an error above). - let truncated_defaults: Box<[Type<'db>]> = + // Truncate defaults to at most `num_fields`. + let default_types: Box<[Type<'db>]> = default_types.into_iter().take(num_fields).collect(); - (field_names, truncated_defaults, true) + + (field_names, default_types, true) } else { - // Couldn't determine fields statically; attribute lookups will return Any. + // Couldn't determine fields statically; attribute lookups will return `Any`. (Box::new([]), Box::new([]), false) } } @@ -6978,7 +7019,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // - For assigned namedtuple calls, the Definition uniquely identifies the namedtuple. // - For dangling calls, compute a relative offset from the scope's node index. let anchor = if let Some(def) = definition { - DynamicClassAnchor::Definition(def) + CallAnchor::Definition(def) } else { let call_node_index = call_expr.node_index.load(); let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); @@ -6988,7 +7029,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let call_u32 = call_node_index .as_u32() .expect("call node should not be NodeIndex::NONE"); - DynamicClassAnchor::ScopeOffset { + CallAnchor::ScopeOffset { scope, offset: call_u32 - anchor_u32, } @@ -7034,7 +7075,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return None; }; - // Validate the field type - report diagnostic if invalid. + // Validate the field type; report diagnostic if invalid. if field_type .in_type_expression(db, self.scope(), self.typevar_binding_context) .is_err() @@ -7163,7 +7204,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { _ => return None, }; - let field_names: Option> = elements + elements .iter() .map(|elt| { // Each element should be a tuple or list like ("field_name", type) or ["field_name", type]. @@ -7172,18 +7213,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ast::Expr::List(list) => &list.elts, _ => return None, }; - if field_spec_elts.len() != 2 { + + let [field_name_expr, field_type_expr] = &field_spec_elts else { return None; - } + }; // First element: field name (string literal). - let field_name_expr = &field_spec_elts[0]; let field_name_ty = self.expression_type(field_name_expr); let field_name_lit = field_name_ty.as_string_literal()?; // Second element: validate the type expression. String literals will be parsed in // the deferred inference phase, so skip those here. - let field_type_expr = &field_spec_elts[1]; if !matches!(field_type_expr, ast::Expr::StringLiteral(_)) { let field_value_ty = self.expression_type(field_type_expr); if field_value_ty @@ -7204,9 +7244,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Some(Name::new(field_name_lit.value(db))) }) - .collect(); - - field_names + .collect() } /// Extract field names from a collections.namedtuple fields argument by looking at the AST directly. From 15648e1622d39603c5781bb83c96c4fce44b6196 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 16 Jan 2026 13:39:57 -0500 Subject: [PATCH 4/4] Deduplicate --- .../resources/mdtest/named_tuple.md | 19 + crates/ty_python_semantic/src/types.rs | 2 +- crates/ty_python_semantic/src/types/class.rs | 400 +++++++----------- crates/ty_python_semantic/src/types/infer.rs | 47 +- .../src/types/infer/builder.rs | 97 +++-- 5 files changed, 245 insertions(+), 320 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/named_tuple.md b/crates/ty_python_semantic/resources/mdtest/named_tuple.md index 10e46985d56024..4f4a9d052d51e2 100644 --- a/crates/ty_python_semantic/resources/mdtest/named_tuple.md +++ b/crates/ty_python_semantic/resources/mdtest/named_tuple.md @@ -317,6 +317,25 @@ reveal_type(url[1]) # revealed: int url[2] ``` +### Functional syntax with Final variable field names + +When field names are `Final` variables, they resolve to their literal string values: + +```py +from typing import Final, NamedTuple + +X: Final = "x" +Y: Final = "y" +N = NamedTuple("N", [(X, int), (Y, int)]) + +reveal_type(N(x=3, y=4).x) # revealed: int +reveal_type(N(x=3, y=4).y) # revealed: int + +# error: [invalid-argument-type] +# error: [invalid-argument-type] +N(x="", y="") +``` + ### Functional syntax with variadic tuple fields When fields are passed as a variadic tuple (e.g., `tuple[..., *tuple[T, ...]]`), we cannot determine diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 39dc4e1600f2be..6e0e912025532e 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -30,7 +30,7 @@ pub(crate) use self::cyclic::{PairVisitor, TypeTransformer}; pub(crate) use self::diagnostic::register_lints; pub use self::diagnostic::{TypeCheckDiagnostics, UNDEFINED_REVEAL, UNRESOLVED_REFERENCE}; pub(crate) use self::infer::{ - CallAnchor, TypeContext, infer_deferred_namedtuple_call_types, infer_deferred_types, + DeferredAnchor, TypeContext, infer_deferred_namedtuple_call_types, infer_deferred_types, infer_definition_types, infer_expression_type, infer_expression_types, infer_scope_types, static_expression_truthiness, }; diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index e369cde77e878b..cda4c9529665a3 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -1,6 +1,5 @@ use std::borrow::Cow; use std::cell::RefCell; -use std::collections::BTreeMap; use std::fmt::Write; use std::sync::{LazyLock, Mutex}; @@ -10,7 +9,7 @@ use super::{ function::FunctionType, infer_scope_types, }; use super::{ - CallAnchor, TypeVarVariance, infer_deferred_namedtuple_call_types, infer_deferred_types, + DeferredAnchor, TypeVarVariance, infer_deferred_namedtuple_call_types, infer_deferred_types, }; use crate::place::{DefinedPlace, TypeOrigin}; use crate::semantic_index::definition::{Definition, DefinitionState}; @@ -177,15 +176,15 @@ fn fields_cycle_initial<'db>( /// Cycle initial function for `DynamicNamedTupleLiteral::mro`. /// -/// Returns an MRO using `Unknown` types for the tuple element types, -/// which breaks the cycle while providing a valid fallback. +/// Calls `tuple_base_class()` which may call `fields()`. If `fields()` is also +/// in the cycle, its `cycle_initial` will return `Unknown` types, breaking the cycle. fn dynamic_namedtuple_mro_cycle_initial<'db>( db: &'db dyn Db, _id: salsa::Id, self_: DynamicNamedTupleLiteral<'db>, ) -> Mro<'db> { let self_base = ClassBase::Class(ClassType::NonGeneric(self_.into())); - let tuple_class = self_.tuple_base_class_for_mro(db); + let tuple_class = self_.tuple_base_class(db); let object_class = KnownClass::Object .to_class_literal(db) .as_class_literal() @@ -198,6 +197,22 @@ fn dynamic_namedtuple_mro_cycle_initial<'db>( ]) } +/// Cycle initial function for `DynamicNamedTupleLiteral::fields`. +/// +/// Returns fields with `Unknown` types to break cycles in recursive namedtuples. +fn dynamic_namedtuple_fields_cycle_initial<'db>( + db: &'db dyn Db, + _id: salsa::Id, + self_: DynamicNamedTupleLiteral<'db>, +) -> Box<[(Name, Type<'db>, Option>)]> { + self_ + .raw_fields(db) + .iter() + .cloned() + .map(|name| (name, Type::unknown(), None)) + .collect() +} + /// A category of classes with code generation capabilities (with synthesized methods). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)] pub(crate) enum CodeGeneratorKind<'db> { @@ -4840,7 +4855,7 @@ pub struct DynamicClassLiteral<'db> { /// uniquely identifies this class and can be used to find the `type()` call. /// - `ScopeOffset`: The `type()` call is "dangling" (not assigned). The offset /// is relative to the enclosing scope's anchor node index. - pub anchor: CallAnchor<'db>, + pub anchor: DeferredAnchor<'db>, /// The class members from the namespace dict (third argument to `type()`). /// Each entry is a (name, type) pair extracted from the dict literal. @@ -4864,16 +4879,16 @@ impl<'db> DynamicClassLiteral<'db> { /// Returns the definition where this class is created, if it was assigned to a variable. pub(crate) fn definition(self, db: &'db dyn Db) -> Option> { match self.anchor(db) { - CallAnchor::Definition(definition) => Some(definition), - CallAnchor::ScopeOffset { .. } => None, + DeferredAnchor::Definition(definition) => Some(definition), + DeferredAnchor::ScopeOffset { .. } => None, } } /// Returns the scope in which this dynamic class was created. pub(crate) fn scope(self, db: &'db dyn Db) -> ScopeId<'db> { match self.anchor(db) { - CallAnchor::Definition(definition) => definition.scope(db), - CallAnchor::ScopeOffset { scope, .. } => scope, + DeferredAnchor::Definition(definition) => definition.scope(db), + DeferredAnchor::ScopeOffset { scope, .. } => scope, } } @@ -4891,16 +4906,16 @@ impl<'db> DynamicClassLiteral<'db> { let module = parsed_module(db, file).load(db); match self.anchor(db) { - CallAnchor::Definition(definition) => { + DeferredAnchor::Definition(definition) => { // For definitions, get the range from the definition's value. // The `type()` call is the value of the assignment. definition .kind(db) .value(&module) - .expect("CallAnchor::Definition should only be used for assignments") + .expect("DeferredAnchor::Definition should only be used for assignments") .range() } - CallAnchor::ScopeOffset { offset, .. } => { + DeferredAnchor::ScopeOffset { offset, .. } => { // For dangling `type()` calls, compute the absolute index from the offset. let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); let anchor_u32 = scope_anchor @@ -5283,6 +5298,41 @@ fn synthesize_namedtuple_class_member<'db>( } } +/// Extract field types from the inferred type of the fields argument. +/// +/// This handles cases where field specs come from variables rather than AST literals. +fn extract_field_types_from_inferred<'db>( + db: &'db dyn Db, + scope: ScopeId<'db>, + fields_type: Type<'db>, +) -> FxIndexMap<&'db str, Type<'db>> { + let mut field_types = FxIndexMap::default(); + + let Some(tuple_spec) = fields_type.tuple_instance_spec(db) else { + return field_types; + }; + let Some(fixed_tuple) = tuple_spec.as_fixed_length() else { + return field_types; + }; + + for field_tuple in fixed_tuple.all_elements() { + // Each field must be a fixed-length tuple of exactly 2 elements. + if let Some(field_spec) = field_tuple.exact_tuple_instance_spec(db) + && let Some(field_fixed) = field_spec.as_fixed_length() + && let [Type::StringLiteral(name_lit), field_type] = field_fixed.all_elements() + { + let field_name = name_lit.value(db); + // Convert value type to type expression. + let field_ty = field_type + .in_type_expression(db, scope, None) + .unwrap_or_else(|error| error.fallback_type); + field_types.insert(field_name, field_ty); + } + } + + field_types +} + /// A namedtuple created via the functional form `namedtuple(name, fields)` or /// `NamedTuple(name, fields)`. /// @@ -5338,7 +5388,7 @@ pub struct DynamicNamedTupleLiteral<'db> { /// uniquely identifies this namedtuple and can be used to find the call. /// - `ScopeOffset`: The call is "dangling" (not assigned). The offset /// is relative to the enclosing scope's anchor node index. - pub anchor: CallAnchor<'db>, + pub anchor: DeferredAnchor<'db>, } impl get_size2::GetSize for DynamicNamedTupleLiteral<'_> {} @@ -5348,16 +5398,16 @@ impl<'db> DynamicNamedTupleLiteral<'db> { /// Returns the definition where this namedtuple is created, if it was assigned to a variable. pub(crate) fn definition(self, db: &'db dyn Db) -> Option> { match self.anchor(db) { - CallAnchor::Definition(definition) => Some(definition), - CallAnchor::ScopeOffset { .. } => None, + DeferredAnchor::Definition(definition) => Some(definition), + DeferredAnchor::ScopeOffset { .. } => None, } } /// Returns the scope in which this dynamic class was created. pub(crate) fn scope(self, db: &'db dyn Db) -> ScopeId<'db> { match self.anchor(db) { - CallAnchor::Definition(definition) => definition.scope(db), - CallAnchor::ScopeOffset { scope, .. } => scope, + DeferredAnchor::Definition(definition) => definition.scope(db), + DeferredAnchor::ScopeOffset { scope, .. } => scope, } } @@ -5373,16 +5423,16 @@ impl<'db> DynamicNamedTupleLiteral<'db> { let module = parsed_module(db, file).load(db); match self.anchor(db) { - CallAnchor::Definition(definition) => { + DeferredAnchor::Definition(definition) => { // For definitions, get the range from the definition's value. // The namedtuple call is the value of the assignment. definition .kind(db) .value(&module) - .expect("CallAnchor::Definition should only be used for assignments") + .expect("DeferredAnchor::Definition should only be used for assignments") .range() } - CallAnchor::ScopeOffset { offset, .. } => { + DeferredAnchor::ScopeOffset { offset, .. } => { // For dangling calls, compute the absolute index from the offset. let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); let anchor_u32 = scope_anchor @@ -5412,7 +5462,11 @@ impl<'db> DynamicNamedTupleLiteral<'db> { /// /// Returns `(name, type, default_type)` tuples where `default_type` is `Some` for /// fields with defaults (only for `collections.namedtuple` via the `defaults` parameter). - #[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] + #[salsa::tracked( + returns(ref), + cycle_initial = dynamic_namedtuple_fields_cycle_initial, + heap_size = ruff_memory_usage::heap_size + )] pub(crate) fn fields(self, db: &'db dyn Db) -> Box<[(Name, Type<'db>, Option>)]> { let raw_fields = self.raw_fields(db); let kind = self.kind(db); @@ -5444,23 +5498,27 @@ impl<'db> DynamicNamedTupleLiteral<'db> { let module = parsed_module(db, file).load(db); // Get the call expression, either from the definition or from the scope offset. - let call_expr: &ast::ExprCall = match self.anchor(db) { - CallAnchor::Definition(definition) => definition - .kind(db) - .value(&module) - .expect("NamedTuple definition should have a value") - .as_call_expr() - .expect("NamedTuple definition value should be a Call"), - CallAnchor::ScopeOffset { offset, .. } => { + let (call_expr, scope_offset): (&ast::ExprCall, Option) = match self.anchor(db) { + DeferredAnchor::Definition(definition) => { + let expr = definition + .kind(db) + .value(&module) + .expect("NamedTuple definition should have a value") + .as_call_expr() + .expect("NamedTuple definition value should be a Call"); + (expr, None) + } + DeferredAnchor::ScopeOffset { offset, .. } => { let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); let anchor_u32 = scope_anchor .as_u32() .expect("anchor should not be NodeIndex::NONE"); let absolute_index = NodeIndex::from(anchor_u32 + offset); - module + let expr = module .get_by_index(absolute_index) .try_into() - .expect("scope offset should point to ExprCall") + .expect("scope offset should point to ExprCall"); + (expr, Some(offset)) } }; @@ -5482,8 +5540,12 @@ impl<'db> DynamicNamedTupleLiteral<'db> { _ => None, }; + // We need scope inference to resolve field names from variables (e.g., Final variables). + let scope_inference = infer_scope_types(db, scope); + // Build a mapping from field names to their type expressions (for AST literals). - let mut field_type_exprs = BTreeMap::<&str, &ast::Expr>::new(); + // Field names may be string literals or variables (e.g., Final variables). + let mut field_type_exprs = FxIndexMap::<&str, &ast::Expr>::default(); if let Some(elements) = elements { for elt in elements { // Each element should be a tuple like ("field_name", type). @@ -5496,194 +5558,74 @@ impl<'db> DynamicNamedTupleLiteral<'db> { continue; }; - // First element: field name (should be a string literal). - if let ast::Expr::StringLiteral(string_lit) = field_name_expr { - let field_name = string_lit.value.to_str(); + // Resolve the field name via inference to handle Final variables. + let field_name_ty = scope_inference.expression_type(field_name_expr); + if let Some(field_name_lit) = field_name_ty.as_string_literal() { + let field_name = field_name_lit.value(db); field_type_exprs.insert(field_name, field_type_expr); } } } - // Build fields by looking up types. - // For definitions with AST literal fields, use deferred inference to support recursion. - // For dangling calls or variable fields, use scope inference. - match self.anchor(db) { - CallAnchor::Definition(definition) => { - // Check if all fields have AST type expressions. - let all_fields_have_ast = raw_fields - .iter() - .all(|name| field_type_exprs.contains_key(name.as_str())); - - if all_fields_have_ast { - let deferred_inference = infer_deferred_types(db, definition); - return raw_fields - .iter() - .cloned() - .map(|name| { - let field_ty = field_type_exprs - .get(name.as_str()) - .map(|type_expr| { - let ty = deferred_inference - .try_expression_type(*type_expr) - .unwrap_or_else(Type::unknown); - // For TypeVar instances, we need to bind them to the definition. - // Other types (class literals, string annotations) are already - // correctly resolved by deferred inference. - if let Type::KnownInstance(KnownInstanceType::TypeVar(_)) = ty { - ty.in_type_expression(db, scope, Some(definition)) - .unwrap_or_else(|error| error.fallback_type) - } else { - ty - } - }) - .unwrap_or_else(Type::unknown); - // `typing.NamedTuple` functional form doesn't support defaults. - (name, field_ty, None) - }) - .collect(); - } + // Build a mapping from field names to their types (from the inferred type). + // This is used as a fallback when field specs come from variables. + let field_types_from_inferred = extract_field_types_from_inferred( + db, + scope, + scope_inference.expression_type(fields_arg), + ); - // Some fields come from variables; we need scope inference for those. - let scope_inference = infer_scope_types(db, scope); - let fields_type = scope_inference.expression_type(fields_arg); + // Get the definition for deferred inference (if this is a definition-based anchor). + let definition = match self.anchor(db) { + DeferredAnchor::Definition(def) => Some(def), + DeferredAnchor::ScopeOffset { .. } => None, + }; - // Build a mapping from field names to their types (from the inferred type). - let mut field_types_from_inferred = BTreeMap::<&str, Type<'db>>::new(); - if let Some(tuple_spec) = fields_type.tuple_instance_spec(db) - && let Some(fixed_tuple) = tuple_spec.as_fixed_length() - { - for field_tuple in fixed_tuple.all_elements() { - // Each field must be a fixed-length tuple of exactly 2 elements. - if let Some(field_spec) = field_tuple.exact_tuple_instance_spec(db) - && let Some(field_fixed) = field_spec.as_fixed_length() - && let [Type::StringLiteral(name_lit), field_type] = - field_fixed.all_elements() - { - let field_name = name_lit.value(db); - // Convert value type to type expression. - let field_ty = field_type - .in_type_expression(db, scope, None) - .unwrap_or_else(|error| error.fallback_type); - field_types_from_inferred.insert(field_name, field_ty); + // Resolve each field's type, preferring deferred inference when available. + // `typing.NamedTuple` functional form doesn't support defaults. + raw_fields + .iter() + .cloned() + .map(|name| { + // Try deferred inference first for fields with AST type expressions. + let field_ty = if let Some(type_expr) = field_type_exprs.get(name.as_str()) { + let deferred_ty = match (definition, scope_offset) { + (Some(def), _) => { + infer_deferred_types(db, def).try_expression_type(*type_expr) } - } - } - - let deferred_inference = infer_deferred_types(db, definition); - raw_fields - .iter() - .cloned() - .map(|name| { - let field_ty = if let Some(type_expr) = field_type_exprs.get(name.as_str()) - { - // Get the inferred type from deferred inference. - deferred_inference + (None, Some(offset)) => { + infer_deferred_namedtuple_call_types(db, scope, offset) .try_expression_type(*type_expr) - .unwrap_or_else(|| { - // Fall back to inferred type from variable. - field_types_from_inferred - .get(name.as_str()) - .copied() - .unwrap_or_else(Type::unknown) - }) - } else { - // No AST expression available, use inferred type from variable. - field_types_from_inferred - .get(name.as_str()) - .copied() - .unwrap_or_else(Type::unknown) - }; - // `typing.NamedTuple` functional form doesn't support defaults. - (name, field_ty, None) - }) - .collect() - } - - CallAnchor::ScopeOffset { offset, .. } => { - // Check if all fields have AST type expressions. - let all_fields_have_ast = raw_fields - .iter() - .all(|name| field_type_exprs.contains_key(name.as_str())); - - if all_fields_have_ast { - // Use deferred inference to support forward references in string annotations. - let deferred_inference = - infer_deferred_namedtuple_call_types(db, scope, offset); - return raw_fields - .iter() - .cloned() - .map(|name| { - let field_ty = field_type_exprs - .get(name.as_str()) - .map(|type_expr| { - deferred_inference - .try_expression_type(*type_expr) - .unwrap_or_else(Type::unknown) - }) - .unwrap_or_else(Type::unknown); - // `typing.NamedTuple` functional form doesn't support defaults. - (name, field_ty, None) - }) - .collect(); - } - - // Some fields come from variables; we need scope inference for those. - let scope_inference = infer_scope_types(db, scope); - let fields_type = scope_inference.expression_type(fields_arg); + } + _ => None, + }; - // Build a mapping from field names to their types (from the inferred type). - let mut field_types_from_inferred = BTreeMap::<&str, Type<'db>>::new(); - if let Some(tuple_spec) = fields_type.tuple_instance_spec(db) - && let Some(fixed_tuple) = tuple_spec.as_fixed_length() - { - for field_tuple in fixed_tuple.all_elements() { - // Each field must be a fixed-length tuple of exactly 2 elements. - if let Some(field_spec) = field_tuple.exact_tuple_instance_spec(db) - && let Some(field_fixed) = field_spec.as_fixed_length() - && let [Type::StringLiteral(name_lit), field_type] = - field_fixed.all_elements() - { - let field_name = name_lit.value(db); - // Convert value type to type expression. - let field_ty = field_type - .in_type_expression(db, scope, None) - .unwrap_or_else(|error| error.fallback_type); - field_types_from_inferred.insert(field_name, field_ty); + if let Some(ty) = deferred_ty { + // For TypeVar instances, bind them to the definition. + if let Type::KnownInstance(KnownInstanceType::TypeVar(_)) = ty { + ty.in_type_expression(db, scope, definition) + .unwrap_or_else(|error| error.fallback_type) + } else { + ty } + } else { + // Fall back to inferred type from variable. + field_types_from_inferred + .get(name.as_str()) + .copied() + .unwrap_or_else(Type::unknown) } - } + } else { + // No AST type expression; use inferred type from variable. + field_types_from_inferred + .get(name.as_str()) + .copied() + .unwrap_or_else(Type::unknown) + }; - // Use deferred inference for AST literal fields, scope inference for variable fields. - let deferred_inference = infer_deferred_namedtuple_call_types(db, scope, offset); - raw_fields - .iter() - .cloned() - .map(|name| { - let field_ty = if let Some(type_expr) = field_type_exprs.get(name.as_str()) - { - // Get the inferred type from deferred inference. - deferred_inference - .try_expression_type(*type_expr) - .unwrap_or_else(|| { - // Fall back to inferred type from variable. - field_types_from_inferred - .get(name.as_str()) - .copied() - .unwrap_or_else(Type::unknown) - }) - } else { - // No AST expression available, use inferred type from variable. - field_types_from_inferred - .get(name.as_str()) - .copied() - .unwrap_or_else(Type::unknown) - }; - // `typing.NamedTuple` functional form doesn't support defaults. - (name, field_ty, None) - }) - .collect() - } - } + (name, field_ty, None) + }) + .collect() } /// Compute the MRO for this namedtuple. @@ -5719,49 +5661,9 @@ impl<'db> DynamicNamedTupleLiteral<'db> { KnownClass::Type.to_class_literal(db) } - /// Compute the specialized tuple class that this namedtuple inherits from for MRO purposes. + /// Compute the specialized tuple class that this namedtuple inherits from. /// /// For example, `namedtuple("Point", [("x", int), ("y", int)])` inherits from `tuple[int, int]`. - /// - /// Note: This method does NOT call `fields()` to avoid cycles during definition inference. - /// Instead, it uses the raw field count to create a tuple with the right arity. - /// For the actual field types, use `fields()` which computes them lazily. - fn tuple_base_class_for_mro(self, db: &'db dyn Db) -> ClassType<'db> { - // If fields are unknown, return `tuple[Unknown, ...]` to avoid false positives - // like index-out-of-bounds errors. - if !self.has_known_fields(db) { - return TupleType::homogeneous(db, Type::unknown()).to_class_type(db); - } - - // Use the raw field count to determine the tuple arity. - // We don't compute actual field types here to avoid cycles. - let raw_fields = self.raw_fields(db); - let field_count = raw_fields.len(); - - // For typing.NamedTuple, we need to compute types lazily. - // For collections.namedtuple, all types are Any. - let element_type = if self.kind(db) == NamedTupleKind::Collections { - Type::any() - } else { - Type::unknown() - }; - - let field_types = std::iter::repeat_n(element_type, field_count); - TupleType::heterogeneous(db, field_types) - .map(|t| t.to_class_type(db)) - .unwrap_or_else(|| { - KnownClass::Tuple - .to_class_literal(db) - .as_class_literal() - .expect("tuple should be a class literal") - .default_specialization(db) - }) - } - - /// Compute the specialized tuple class with actual field types. - /// - /// This method calls `fields()` to get the actual field types. - /// Use `tuple_base_class_for_mro()` for MRO computation to avoid cycles. pub(crate) fn tuple_base_class(self, db: &'db dyn Db) -> ClassType<'db> { // If fields are unknown, return `tuple[Unknown, ...]` to avoid false positives // like index-out-of-bounds errors. diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index a1796fd55e74a3..e3a456ad2d2f6d 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -162,7 +162,7 @@ pub(crate) fn infer_deferred_types<'db>( TypeInferenceBuilder::new( db, - InferenceRegion::Deferred(CallAnchor::Definition(definition)), + InferenceRegion::Deferred(DeferredAnchor::Definition(definition)), index, &module, ) @@ -211,7 +211,7 @@ pub(crate) fn infer_deferred_namedtuple_call_types<'db>( TypeInferenceBuilder::new( db, - InferenceRegion::Deferred(CallAnchor::ScopeOffset { scope, offset }), + InferenceRegion::Deferred(DeferredAnchor::ScopeOffset { scope, offset }), index, &module, ) @@ -539,39 +539,40 @@ pub(crate) fn nearest_enclosing_function<'db>( }) } -/// Identifies a call expression for deferred type inference. +/// Identifies a region for deferred type inference. /// -/// This is used to locate call expressions (like `NamedTuple(...)`, `TypedDict(...)`, -/// or `type(...)`) that may contain string annotations requiring deferred resolution. +/// Deferred inference is used when type expressions contain forward references +/// (string annotations) that need to be resolved after the full scope is available. /// /// There are two cases: -/// - For assigned calls (e.g., `Point = NamedTuple(...)`), the `Definition` uniquely -/// identifies the call and we can get the expression from the definition's value. -/// - For dangling calls (e.g., used directly as base classes), we store a relative -/// offset from the enclosing scope's anchor node index to locate the call. +/// - For definitions (functions, classes, assignments), the `Definition` uniquely +/// identifies the region containing annotations that need deferred resolution. +/// - For dangling calls (e.g., `NamedTuple(...)` used directly as a base class), +/// we store a relative offset from the enclosing scope's anchor node index. #[derive( Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, salsa::Update, get_size2::GetSize, )] -pub enum CallAnchor<'db> { - /// The call is assigned to a variable. +pub enum DeferredAnchor<'db> { + /// A definition containing annotations that need deferred resolution. /// - /// The `Definition` uniquely identifies this call. The call expression - /// is the `value` of the assignment, so we can get its range from the definition. + /// This covers functions (parameter/return annotations), classes (base classes), + /// type variables, and assignments (including `NamedTuple(...)` assignments). Definition(Definition<'db>), - /// The call is "dangling" (not assigned to a variable). + /// A dangling call expression (not assigned to a variable). /// /// The offset is relative to the enclosing scope's anchor node index. /// For module scope, this is equivalent to an absolute index (anchor is 0). + /// This is used for patterns like `class Foo(NamedTuple("Foo", [...]))`. ScopeOffset { scope: ScopeId<'db>, offset: u32 }, } -impl<'db> CallAnchor<'db> { - /// Returns the scope in which this call exists. +impl<'db> DeferredAnchor<'db> { + /// Returns the scope containing this deferred region. pub(crate) fn scope(self, db: &'db dyn Db) -> ScopeId<'db> { match self { - CallAnchor::Definition(definition) => definition.scope(db), - CallAnchor::ScopeOffset { scope, .. } => scope, + DeferredAnchor::Definition(definition) => definition.scope(db), + DeferredAnchor::ScopeOffset { scope, .. } => scope, } } } @@ -583,12 +584,12 @@ pub(crate) enum InferenceRegion<'db> { Expression(Expression<'db>, TypeContext<'db>), /// Infer types for a [`Definition`] Definition(Definition<'db>), - /// Infer deferred types for a dynamic class call (`NamedTuple`, `TypedDict`, etc.) + /// Infer deferred types (forward references / string annotations). /// - /// The anchor identifies the call - either by its definition (for assigned calls like - /// `Point = NamedTuple(...)`) or by scope offset (for dangling calls used directly - /// as base classes). - Deferred(CallAnchor<'db>), + /// The anchor identifies the region - either by its definition (for functions, + /// classes, assignments) or by scope offset (for dangling calls like + /// `NamedTuple(...)` used directly as base classes). + Deferred(DeferredAnchor<'db>), /// Infer types for an entire [`ScopeId`] Scope(ScopeId<'db>), } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 8c57efda991145..29c574d11f552d 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -22,7 +22,7 @@ use ty_module_resolver::{ resolve_module, search_paths, }; -use super::CallAnchor; +use super::DeferredAnchor; use super::{ DefinitionInference, DefinitionInferenceExtra, ExpressionInference, ExpressionInferenceExtra, InferenceRegion, ScopeInference, ScopeInferenceExtra, infer_deferred_types, @@ -1654,12 +1654,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } } - fn infer_region_deferred(&mut self, anchor: CallAnchor<'db>) { + fn infer_region_deferred(&mut self, anchor: DeferredAnchor<'db>) { match anchor { - CallAnchor::Definition(definition) => { + DeferredAnchor::Definition(definition) => { self.infer_region_deferred_definition(definition); } - CallAnchor::ScopeOffset { scope, offset } => { + DeferredAnchor::ScopeOffset { scope, offset } => { self.infer_region_deferred_scope_offset(scope, offset); } } @@ -6455,7 +6455,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // - For assigned `type()` calls, the Definition uniquely identifies the class. // - For dangling calls, compute a relative offset from the scope's node index. let anchor = if let Some(def) = definition { - CallAnchor::Definition(def) + DeferredAnchor::Definition(def) } else { let call_node_index = call_expr.node_index().load(); let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); @@ -6465,7 +6465,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let call_u32 = call_node_index .as_u32() .expect("call node should not be NodeIndex::NONE"); - CallAnchor::ScopeOffset { + DeferredAnchor::ScopeOffset { scope, offset: call_u32 - anchor_u32, } @@ -7019,7 +7019,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // - For assigned namedtuple calls, the Definition uniquely identifies the namedtuple. // - For dangling calls, compute a relative offset from the scope's node index. let anchor = if let Some(def) = definition { - CallAnchor::Definition(def) + DeferredAnchor::Definition(def) } else { let call_node_index = call_expr.node_index.load(); let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0)); @@ -7029,7 +7029,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let call_u32 = call_node_index .as_u32() .expect("call node should not be NodeIndex::NONE"); - CallAnchor::ScopeOffset { + DeferredAnchor::ScopeOffset { scope, offset: call_u32 - anchor_u32, } @@ -7063,7 +7063,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let tuple_spec = fields_type.tuple_instance_spec(db)?; let fixed_tuple = tuple_spec.as_fixed_length()?; - // Extract field names and validate types from the inferred type. + // Check if we have AST literals for better error locations. + let has_ast_literals = matches!(fields_arg, ast::Expr::List(_) | ast::Expr::Tuple(_)); + + // Extract field names from the inferred type. let field_names: Option> = fixed_tuple .all_elements() .iter() @@ -7075,10 +7078,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return None; }; - // Validate the field type; report diagnostic if invalid. - if field_type - .in_type_expression(db, self.scope(), self.typevar_binding_context) - .is_err() + // Validate the field type only when fields come from a variable (no AST literals). + // When we have AST literals, we validate below with better error locations. + if !has_ast_literals + && field_type + .in_type_expression(db, self.scope(), self.typevar_binding_context) + .is_err() { // Report diagnostic at the fields argument (best we can do for variable case). if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, fields_arg) @@ -7094,42 +7099,40 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { }) .collect(); - // Also validate AST elements if we have direct literals (for better error location). - if field_names.is_some() { - let elements: Option<&[ast::Expr]> = match fields_arg { - ast::Expr::List(list) => Some(&list.elts), - ast::Expr::Tuple(tuple) => Some(&tuple.elts), - _ => None, + // Validate AST elements when we have direct literals (for better error locations). + if field_names.is_some() && has_ast_literals { + let elements: &[ast::Expr] = match fields_arg { + ast::Expr::List(list) => &list.elts, + ast::Expr::Tuple(tuple) => &tuple.elts, + _ => &[], }; - if let Some(elements) = elements { - for elt in elements { - // Check that each element is a valid (name, type) pair. - let field_spec_elts: &[ast::Expr] = match elt { - ast::Expr::Tuple(tuple) => &tuple.elts, - ast::Expr::List(list) => &list.elts, - _ => continue, - }; - if field_spec_elts.len() == 2 { - // Validate the type expression (will be re-inferred in deferred phase). - let field_type_expr = &field_spec_elts[1]; - - // Skip validation for string literals; they're valid string annotations - // that will be parsed as types in the deferred phase. - if !matches!(field_type_expr, ast::Expr::StringLiteral(_)) { - let field_value_ty = self.expression_type(field_type_expr); - if field_value_ty - .in_type_expression(db, self.scope(), self.typevar_binding_context) - .is_err() + for elt in elements { + // Check that each element is a valid (name, type) pair. + let field_spec_elts: &[ast::Expr] = match elt { + ast::Expr::Tuple(tuple) => &tuple.elts, + ast::Expr::List(list) => &list.elts, + _ => continue, + }; + if field_spec_elts.len() == 2 { + // Validate the type expression (will be re-inferred in deferred phase). + let field_type_expr = &field_spec_elts[1]; + + // Skip validation for string literals; they're valid string annotations + // that will be parsed as types in the deferred phase. + if !matches!(field_type_expr, ast::Expr::StringLiteral(_)) { + let field_value_ty = self.expression_type(field_type_expr); + if field_value_ty + .in_type_expression(db, self.scope(), self.typevar_binding_context) + .is_err() + { + if let Some(builder) = self + .context + .report_lint(&INVALID_TYPE_FORM, field_type_expr) { - if let Some(builder) = self - .context - .report_lint(&INVALID_TYPE_FORM, field_type_expr) - { - builder.into_diagnostic(format_args!( - "Object of type `{}` is not valid as a `NamedTuple` field type", - field_value_ty.display(db) - )); - } + builder.into_diagnostic(format_args!( + "Object of type `{}` is not valid as a `NamedTuple` field type", + field_value_ty.display(db) + )); } } }