diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index 6c092db927..3236f1aaba 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -7741,6 +7741,68 @@ TypedDi ); } + /// Tests that `xs = ["..."]; xs[0].` gets completions + /// appropriate for `str`. + #[test] + fn dynamic_type_list_no_type_annotation() { + let builder = completion_test_builder( + r#" +my_list = ["foo"] +my_list[0].remove +"#, + ); + assert_snapshot!( + builder.build().snapshot(), + @r" + removeprefix + removesuffix + ", + ); + } + + /// Tests that when we have `Any | T` that we offer + /// completions for `T`. + #[test] + fn dynamic_type_with_type_annotation() { + let builder = completion_test_builder( + r#" +from typing import Any + +def f(x: Any | str): + x.remove +"#, + ); + assert_snapshot!( + builder.build().snapshot(), + @r" + removeprefix + removesuffix + ", + ); + } + + /// Tests that when we have `(U & Any) | T` that we offer + /// completions for `T`. + #[test] + fn dynamic_type_with_intersection_type_annotation() { + let builder = completion_test_builder( + r#" +from typing import Any +from ty_extensions import Intersection + +def f(x: Intersection[int, Any] | str): + x.remove +"#, + ); + assert_snapshot!( + builder.build().snapshot(), + @r" + removeprefix + removesuffix + ", + ); + } + /// A way to create a simple single-file (named `main.py`) completion test /// builder. /// diff --git a/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md b/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md index f0f7db664c..d8aec60bd2 100644 --- a/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md +++ b/crates/ty_python_semantic/resources/mdtest/ide_support/all_members.md @@ -361,6 +361,52 @@ def f(union: A | B): static_assert(not has_member(union, "only_on_b")) ``` +Unless one of the elements of the union is `Any`, thus making it dynamic. In which case, we consider +items on the intersection of the non-`Any` elements: + +```py +from typing import Any +from ty_extensions import has_member, static_assert + +class A: + on_both: int = 1 + only_on_a: str = "a" + +class B: + on_both: int = 2 + only_on_b: str = "b" + +def f(union: Any | A): + static_assert(has_member(union, "on_both")) + static_assert(has_member(union, "only_on_a")) + +def g(union: Any | A | B): + static_assert(has_member(union, "on_both")) + static_assert(not has_member(union, "only_on_a")) + static_assert(not has_member(union, "only_on_b")) +``` + +Similarly, unioning with an intersection involving `Any` is treated the same as if it was just +unioned with `Any`: + +```py +from typing import Any +from ty_extensions import Intersection, has_member, static_assert + +class A: + on_both: int = 1 + only_on_a: str = "a" + +class B: + on_both: int = 2 + only_on_b: str = "b" + +def f(x: Intersection[Any, A] | B): + static_assert(has_member(x, "on_both")) + static_assert(not has_member(x, "only_on_a")) + static_assert(has_member(x, "only_on_b")) +``` + ### Intersections #### Only positive types diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index c1244ac7b4..39a993e27a 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1064,7 +1064,7 @@ impl<'db> Type<'db> { } } - const fn is_dynamic(&self) -> bool { + pub(crate) const fn is_dynamic(&self) -> bool { matches!(self, Type::Dynamic(_)) } diff --git a/crates/ty_python_semantic/src/types/list_members.rs b/crates/ty_python_semantic/src/types/list_members.rs index fff93548d8..1c7d20b5a6 100644 --- a/crates/ty_python_semantic/src/types/list_members.rs +++ b/crates/ty_python_semantic/src/types/list_members.rs @@ -163,14 +163,33 @@ impl<'db> AllMembers<'db> { fn extend_with_type(&mut self, db: &'db dyn Db, ty: Type<'db>) { match ty { - Type::Union(union) => self.members.extend( - union - .elements(db) - .iter() - .map(|ty| AllMembers::of(db, *ty).members) - .reduce(|acc, members| acc.intersection(&members).cloned().collect()) - .unwrap_or_default(), - ), + Type::Union(union) => { + fn is_dynamic(db: &dyn Db, ty: Type<'_>) -> bool { + // We don't need to use recursion here because + // `Type` guarantees that unions/intersections + // are kept in DNF (i.e., they are flattened). + ty.is_dynamic() + || match ty { + Type::Intersection(intersection) => { + intersection.positive(db).iter().any(Type::is_dynamic) + } + _ => false, + } + } + + let union = match union.filter(db, |&ty| !is_dynamic(db, ty)) { + Type::Union(union) => union, + ty => return self.extend_with_type(db, ty), + }; + self.members.extend( + union + .elements(db) + .iter() + .map(|ty| AllMembers::of(db, *ty).members) + .reduce(|acc, members| acc.intersection(&members).cloned().collect()) + .unwrap_or_default(), + ); + } Type::Intersection(intersection) => self.members.extend( intersection