diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/complex_target.md b/crates/ty_python_semantic/resources/mdtest/narrow/complex_target.md index 96b12d845d..19d35b73b5 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/complex_target.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/complex_target.md @@ -104,6 +104,8 @@ class C: value: str | None def foo(c: C): + # The truthiness check `c.value` narrows to `str & ~AlwaysFalsy`. + # The subsequent `len(c.value)` doesn't narrow further since `str` is not narrowable by len(). if c.value and len(c.value): reveal_type(c.value) # revealed: str & ~AlwaysFalsy @@ -114,7 +116,7 @@ def foo(c: C): if c.value is None or not len(c.value): reveal_type(c.value) # revealed: str | None else: # c.value is not None and len(c.value) - # TODO: should be # `str & ~AlwaysFalsy` + # `c.value is not None` narrows to `str`, but `str` is not narrowable by len(). reveal_type(c.value) # revealed: str ``` diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/len.md b/crates/ty_python_semantic/resources/mdtest/narrow/len.md new file mode 100644 index 0000000000..c9d553ffbe --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/narrow/len.md @@ -0,0 +1,131 @@ +# Narrowing for `len(..)` checks + +When `len(x)` is used in a boolean context, we can narrow the type of `x` based on whether `len(x)` +is truthy (non-zero) or falsy (zero). + +We apply `~AlwaysFalsy` narrowing when ANY part of the type is narrowable (string/bytes literals, +`LiteralString`, tuples). This removes types that are always falsy (like `Literal[""]`) while +leaving non-narrowable types (like `str`, `list`) unchanged. + +## String literals + +The intersection with `~AlwaysFalsy` simplifies to just the non-empty literal. + +```py +from typing import Literal + +def _(x: Literal["foo", ""]): + if len(x): + reveal_type(x) # revealed: Literal["foo"] + else: + reveal_type(x) # revealed: Literal[""] +``` + +## Bytes literals + +```py +from typing import Literal + +def _(x: Literal[b"foo", b""]): + if len(x): + reveal_type(x) # revealed: Literal[b"foo"] + else: + reveal_type(x) # revealed: Literal[b""] +``` + +## LiteralString + +```toml +[environment] +python-version = "3.11" +``` + +```py +from typing import LiteralString + +def _(x: LiteralString): + if len(x): + reveal_type(x) # revealed: LiteralString & ~Literal[""] + else: + reveal_type(x) # revealed: Literal[""] +``` + +## Tuples + +Ideally we'd narrow these types further, e.g. to `tuple[int, ...] & ~tuple[()]` in the positive case +and `tuple[()]` in the negative case (see ). + +```py +def _(x: tuple[int, ...]): + if len(x): + reveal_type(x) # revealed: tuple[int, ...] & ~AlwaysFalsy + else: + reveal_type(x) # revealed: tuple[int, ...] & ~AlwaysTruthy +``` + +## Unions of narrowable types + +```py +from typing import Literal + +def _(x: Literal["foo", ""] | tuple[int, ...]): + if len(x): + reveal_type(x) # revealed: Literal["foo"] | (tuple[int, ...] & ~AlwaysFalsy) + else: + reveal_type(x) # revealed: Literal[""] | (tuple[int, ...] & ~AlwaysTruthy) +``` + +## Types that are not narrowed + +For `str`, `list`, and other types where a subclass could have a `__bool__` that disagrees with +`__len__`, we do not narrow: + +```py +def not_narrowed_str(x: str): + if len(x): + # No narrowing because `str` could be subclassed with a custom `__bool__` + reveal_type(x) # revealed: str + +def not_narrowed_list(x: list[int]): + if len(x): + # No narrowing because `list` could be subclassed with a custom `__bool__` + reveal_type(x) # revealed: list[int] +``` + +## Mixed unions (narrowable and non-narrowable) + +When a union contains both narrowable and non-narrowable types, we narrow the narrowable parts while +leaving the non-narrowable parts unchanged: + +```py +from typing import Literal + +def _(x: Literal["foo", ""] | list[int]): + if len(x): + # `Literal[""]` is removed, `list[int]` is unchanged + reveal_type(x) # revealed: Literal["foo"] | list[int] + else: + reveal_type(x) # revealed: Literal[""] | list[int] +``` + +## Narrowing away empty literals + +This pattern is common when a prior truthiness check narrows a type, and then a conditional +expression adds an empty literal back: + +```py +def _(lines: list[str]): + for line in lines: + if not line: + continue + + reveal_type(line) # revealed: str & ~AlwaysFalsy + value = line if len(line) < 3 else "" + reveal_type(value) # revealed: (str & ~AlwaysFalsy) | Literal[""] + + if len(value): + # `Literal[""]` is removed, `str & ~AlwaysFalsy` is unchanged + reveal_type(value) # revealed: str & ~AlwaysFalsy + # Accessing value[0] is safe here + _ = value[0] +``` diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 4aa8b85b6f..cc9c0ca0f6 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -459,6 +459,82 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { .expect("We should always have a place for every `PlaceExpr`") } + /// Check if a type is directly narrowable by `len()` (without considering unions or intersections). + /// + /// These are types where we know `__bool__` and `__len__` are consistent and the type + /// cannot be subclassed with a `__bool__` that disagrees. + fn is_base_type_narrowable_by_len(db: &'db dyn Db, ty: Type<'db>) -> bool { + match ty { + Type::StringLiteral(_) | Type::LiteralString | Type::BytesLiteral(_) => true, + Type::NominalInstance(instance) => instance.tuple_spec(db).is_some(), + _ => false, + } + } + + /// Narrow a type based on `len()`, only narrowing the parts that are safe to narrow. + /// + /// For narrowable types (literals, tuples), we apply `~AlwaysFalsy` (positive) or + /// `~AlwaysTruthy` (negative). For non-narrowable types, we return them unchanged. + /// + /// Returns `None` if no part of the type is narrowable. + fn narrow_type_by_len(db: &'db dyn Db, ty: Type<'db>, is_positive: bool) -> Option> { + match ty { + Type::Union(union) => { + let mut has_narrowable = false; + let narrowed_elements: Vec<_> = union + .elements(db) + .iter() + .map(|element| { + if let Some(narrowed) = Self::narrow_type_by_len(db, *element, is_positive) + { + has_narrowable = true; + narrowed + } else { + // Non-narrowable elements are kept unchanged. + *element + } + }) + .collect(); + + if has_narrowable { + Some(UnionType::from_elements(db, narrowed_elements)) + } else { + None + } + } + Type::Intersection(intersection) => { + // For intersections, check if any positive element is narrowable. + let positive = intersection.positive(db); + let has_narrowable = positive + .iter() + .any(|element| Self::is_base_type_narrowable_by_len(db, *element)); + + if has_narrowable { + // Apply the narrowing constraint to the whole intersection. + let mut builder = IntersectionBuilder::new(db).add_positive(ty); + if is_positive { + builder = builder.add_negative(Type::AlwaysFalsy); + } else { + builder = builder.add_negative(Type::AlwaysTruthy); + } + Some(builder.build()) + } else { + None + } + } + _ if Self::is_base_type_narrowable_by_len(db, ty) => { + let mut builder = IntersectionBuilder::new(db).add_positive(ty); + if is_positive { + builder = builder.add_negative(Type::AlwaysFalsy); + } else { + builder = builder.add_negative(Type::AlwaysTruthy); + } + Some(builder.build()) + } + _ => None, + } + } + fn evaluate_simple_expr( &mut self, expr: &ast::Expr, @@ -901,6 +977,27 @@ impl<'db, 'ast> NarrowingConstraintsBuilder<'db, 'ast> { guarded_ty.negate_if(self.db, !is_positive), )])) } + // For the expression `len(E)`, we narrow the type based on whether len(E) is truthy + // (i.e., whether E is non-empty). We only narrow the parts of the type where we know + // `__bool__` and `__len__` are consistent (literals, tuples). Non-narrowable parts + // (str, list, etc.) are kept unchanged. + Type::FunctionLiteral(function_type) + if expr_call.arguments.args.len() == 1 + && expr_call.arguments.keywords.is_empty() + && function_type.known(self.db) == Some(KnownFunction::Len) => + { + let arg = &expr_call.arguments.args[0]; + let arg_ty = inference.expression_type(arg); + + // Narrow only the parts of the type that are safe to narrow based on len(). + if let Some(narrowed_ty) = Self::narrow_type_by_len(self.db, arg_ty, is_positive) { + let target = place_expr(arg)?; + let place = self.expect_place(&target); + Some(NarrowingConstraints::from_iter([(place, narrowed_ty)])) + } else { + None + } + } Type::FunctionLiteral(function_type) if expr_call.arguments.keywords.is_empty() => { let [first_arg, second_arg] = &*expr_call.arguments.args else { return None;