Skip to content

Commit 913f136

Browse files
AlexWaygoodntBre
andauthored
[ty] Offer "Did you mean...?" suggestions for unresolved from imports and unresolved attributes (#18705)
Co-authored-by: Brent Westbrook <[email protected]>
1 parent c7e020d commit 913f136

14 files changed

+794
-96
lines changed

_typos.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ extend-exclude = [
88
# words naturally. It's annoying to have to make all
99
# of them actually words. So just ignore typos here.
1010
"crates/ty_ide/src/completion.rs",
11+
# Same for "Did you mean...?" levenshtein tests.
12+
"crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs",
1113
]
1214

1315
[default.extend-words]

crates/ty/docs/rules.md

Lines changed: 56 additions & 56 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ty_python_semantic/resources/mdtest/attributes.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2167,6 +2167,57 @@ reveal_type(Foo.BAR.value) # revealed: @Todo(Attribute access on enum classes)
21672167
reveal_type(Foo.__members__) # revealed: @Todo(Attribute access on enum classes)
21682168
```
21692169

2170+
## Suggestions for obvious typos
2171+
2172+
<!-- snapshot-diagnostics -->
2173+
2174+
For obvious typos, we add a "Did you mean...?" suggestion to the diagnostic.
2175+
2176+
```py
2177+
import collections
2178+
2179+
print(collections.dequee) # error: [unresolved-attribute]
2180+
```
2181+
2182+
But the suggestion is suppressed if the only close matches start with a leading underscore:
2183+
2184+
```py
2185+
class Foo:
2186+
_bar = 42
2187+
2188+
print(Foo.bar) # error: [unresolved-attribute]
2189+
```
2190+
2191+
The suggestion is not suppressed if the typo itself starts with a leading underscore, however:
2192+
2193+
```py
2194+
print(Foo._barr) # error: [unresolved-attribute]
2195+
```
2196+
2197+
And in method contexts, the suggestion is never suppressed if accessing an attribute on an instance
2198+
of the method's enclosing class:
2199+
2200+
```py
2201+
class Bar:
2202+
_attribute = 42
2203+
2204+
def f(self, x: "Bar"):
2205+
# TODO: we should emit `[unresolved-attribute]` here, should have the same behaviour as `x.attribute` below
2206+
print(self.attribute)
2207+
2208+
# We give a suggestion here, even though the only good candidates start with underscores and the typo does not,
2209+
# because we're in a method context and `x` is an instance of the enclosing class.
2210+
print(x.attribute) # error: [unresolved-attribute]
2211+
2212+
class Baz:
2213+
def f(self, x: Bar):
2214+
# No suggestion is given here, because:
2215+
# - the good suggestions all start with underscores
2216+
# - the typo does not start with an underscore
2217+
# - We *are* in a method context, but `x` is not an instance of the enclosing class
2218+
print(x.attribute) # error: [unresolved-attribute]
2219+
```
2220+
21702221
## References
21712222

21722223
Some of the tests in the *Class and instance variables* section draw inspiration from

crates/ty_python_semantic/resources/mdtest/import/basic.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,39 @@ python-version = "3.13"
205205
import aifc # error: [unresolved-import]
206206
from distutils import sysconfig # error: [unresolved-import]
207207
```
208+
209+
## `from` import that has a typo
210+
211+
We offer a "Did you mean?" subdiagnostic suggestion if there's a name in the module that's
212+
reasonably similar to the unresolved member.
213+
214+
<!-- snapshot-diagnostics -->
215+
216+
`foo.py`:
217+
218+
```py
219+
from collections import dequee # error: [unresolved-import]
220+
```
221+
222+
However, we suppress the suggestion if the only close matches in the module start with a leading
223+
underscore:
224+
225+
`bar.py`:
226+
227+
```py
228+
from baz import foo # error: [unresolved-import]
229+
```
230+
231+
`baz.py`:
232+
233+
```py
234+
_foo = 42
235+
```
236+
237+
The suggestion is never suppressed if the typo itself starts with a leading underscore, however:
238+
239+
`eggs.py`:
240+
241+
```py
242+
from baz import _fooo # error: [unresolved-import]
243+
```
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
---
2+
source: crates/ty_test/src/lib.rs
3+
expression: snapshot
4+
---
5+
---
6+
mdtest name: attributes.md - Attributes - Suggestions for obvious typos
7+
mdtest path: crates/ty_python_semantic/resources/mdtest/attributes.md
8+
---
9+
10+
# Python source files
11+
12+
## mdtest_snippet.py
13+
14+
```
15+
1 | import collections
16+
2 |
17+
3 | print(collections.dequee) # error: [unresolved-attribute]
18+
4 | class Foo:
19+
5 | _bar = 42
20+
6 |
21+
7 | print(Foo.bar) # error: [unresolved-attribute]
22+
8 | print(Foo._barr) # error: [unresolved-attribute]
23+
9 | class Bar:
24+
10 | _attribute = 42
25+
11 |
26+
12 | def f(self, x: "Bar"):
27+
13 | # TODO: we should emit `[unresolved-attribute]` here, should have the same behaviour as `x.attribute` below
28+
14 | print(self.attribute)
29+
15 |
30+
16 | # We give a suggestion here, even though the only good candidates start with underscores and the typo does not,
31+
17 | # because we're in a method context and `x` is an instance of the enclosing class.
32+
18 | print(x.attribute) # error: [unresolved-attribute]
33+
19 |
34+
20 | class Baz:
35+
21 | def f(self, x: Bar):
36+
22 | # No suggestion is given here, because:
37+
23 | # - the good suggestions all start with underscores
38+
24 | # - the typo does not start with an underscore
39+
25 | # - We *are* in a method context, but `x` is not an instance of the enclosing class
40+
26 | print(x.attribute) # error: [unresolved-attribute]
41+
```
42+
43+
# Diagnostics
44+
45+
```
46+
error[unresolved-attribute]: Type `<module 'collections'>` has no attribute `dequee`
47+
--> src/mdtest_snippet.py:3:7
48+
|
49+
1 | import collections
50+
2 |
51+
3 | print(collections.dequee) # error: [unresolved-attribute]
52+
| ^^^^^^^^^^^^^^^^^^ Did you mean `deque`?
53+
4 | class Foo:
54+
5 | _bar = 42
55+
|
56+
info: rule `unresolved-attribute` is enabled by default
57+
58+
```
59+
60+
```
61+
error[unresolved-attribute]: Type `<class 'Foo'>` has no attribute `bar`
62+
--> src/mdtest_snippet.py:7:7
63+
|
64+
5 | _bar = 42
65+
6 |
66+
7 | print(Foo.bar) # error: [unresolved-attribute]
67+
| ^^^^^^^
68+
8 | print(Foo._barr) # error: [unresolved-attribute]
69+
9 | class Bar:
70+
|
71+
info: rule `unresolved-attribute` is enabled by default
72+
73+
```
74+
75+
```
76+
error[unresolved-attribute]: Type `<class 'Foo'>` has no attribute `_barr`
77+
--> src/mdtest_snippet.py:8:7
78+
|
79+
7 | print(Foo.bar) # error: [unresolved-attribute]
80+
8 | print(Foo._barr) # error: [unresolved-attribute]
81+
| ^^^^^^^^^ Did you mean `_bar`?
82+
9 | class Bar:
83+
10 | _attribute = 42
84+
|
85+
info: rule `unresolved-attribute` is enabled by default
86+
87+
```
88+
89+
```
90+
error[unresolved-attribute]: Type `Bar` has no attribute `attribute`
91+
--> src/mdtest_snippet.py:18:15
92+
|
93+
16 | # We give a suggestion here, even though the only good candidates start with underscores and the typo does not,
94+
17 | # because we're in a method context and `x` is an instance of the enclosing class.
95+
18 | print(x.attribute) # error: [unresolved-attribute]
96+
| ^^^^^^^^^^^ Did you mean `_attribute`?
97+
19 |
98+
20 | class Baz:
99+
|
100+
info: rule `unresolved-attribute` is enabled by default
101+
102+
```
103+
104+
```
105+
error[unresolved-attribute]: Type `Bar` has no attribute `attribute`
106+
--> src/mdtest_snippet.py:26:15
107+
|
108+
24 | # - the typo does not start with an underscore
109+
25 | # - We *are* in a method context, but `x` is not an instance of the enclosing class
110+
26 | print(x.attribute) # error: [unresolved-attribute]
111+
| ^^^^^^^^^^^
112+
|
113+
info: rule `unresolved-attribute` is enabled by default
114+
115+
```
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
source: crates/ty_test/src/lib.rs
3+
expression: snapshot
4+
---
5+
---
6+
mdtest name: basic.md - Structures - `from` import that has a typo
7+
mdtest path: crates/ty_python_semantic/resources/mdtest/import/basic.md
8+
---
9+
10+
# Python source files
11+
12+
## foo.py
13+
14+
```
15+
1 | from collections import dequee # error: [unresolved-import]
16+
```
17+
18+
## bar.py
19+
20+
```
21+
1 | from baz import foo # error: [unresolved-import]
22+
```
23+
24+
## baz.py
25+
26+
```
27+
1 | _foo = 42
28+
```
29+
30+
## eggs.py
31+
32+
```
33+
1 | from baz import _fooo # error: [unresolved-import]
34+
```
35+
36+
# Diagnostics
37+
38+
```
39+
error[unresolved-import]: Module `collections` has no member `dequee`
40+
--> src/foo.py:1:25
41+
|
42+
1 | from collections import dequee # error: [unresolved-import]
43+
| ^^^^^^ Did you mean `deque`?
44+
|
45+
info: rule `unresolved-import` is enabled by default
46+
47+
```
48+
49+
```
50+
error[unresolved-import]: Module `baz` has no member `foo`
51+
--> src/bar.py:1:17
52+
|
53+
1 | from baz import foo # error: [unresolved-import]
54+
| ^^^
55+
|
56+
info: rule `unresolved-import` is enabled by default
57+
58+
```
59+
60+
```
61+
error[unresolved-import]: Module `baz` has no member `_fooo`
62+
--> src/eggs.py:1:17
63+
|
64+
1 | from baz import _fooo # error: [unresolved-import]
65+
| ^^^^^ Did you mean `_foo`?
66+
|
67+
info: rule `unresolved-import` is enabled by default
68+
69+
```

crates/ty_python_semantic/src/semantic_model.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use crate::module_resolver::{Module, resolve_module};
1010
use crate::semantic_index::ast_ids::HasScopedExpressionId;
1111
use crate::semantic_index::place::FileScopeId;
1212
use crate::semantic_index::semantic_index;
13-
use crate::types::ide_support::all_declarations_and_bindings;
13+
use crate::types::all_members::all_declarations_and_bindings;
1414
use crate::types::{Type, binding_type, infer_scope_types};
1515

1616
pub struct SemanticModel<'db> {

crates/ty_python_semantic/src/types.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ use crate::semantic_index::definition::Definition;
3838
use crate::semantic_index::place::{ScopeId, ScopedPlaceId};
3939
use crate::semantic_index::{imported_modules, place_table, semantic_index};
4040
use crate::suppression::check_suppressions;
41+
pub use crate::types::all_members::all_members;
4142
use crate::types::call::{Binding, Bindings, CallArgumentTypes, CallableBinding};
4243
pub(crate) use crate::types::class_base::ClassBase;
4344
use crate::types::context::{LintDiagnosticGuard, LintDiagnosticGuardBuilder};
@@ -46,7 +47,6 @@ use crate::types::function::{
4647
DataclassTransformerParams, FunctionSpans, FunctionType, KnownFunction,
4748
};
4849
use crate::types::generics::{GenericContext, PartialSpecialization, Specialization};
49-
pub use crate::types::ide_support::all_members;
5050
use crate::types::infer::infer_unpack_types;
5151
use crate::types::mro::{Mro, MroError, MroIterator};
5252
pub(crate) use crate::types::narrow::infer_narrowing_constraint;
@@ -58,6 +58,7 @@ use instance::Protocol;
5858
pub use instance::{NominalInstanceType, ProtocolInstanceType};
5959
pub use special_form::SpecialFormType;
6060

61+
pub(crate) mod all_members;
6162
mod builder;
6263
mod call;
6364
mod class;
@@ -67,7 +68,6 @@ mod diagnostic;
6768
mod display;
6869
mod function;
6970
mod generics;
70-
pub(crate) mod ide_support;
7171
mod infer;
7272
mod instance;
7373
mod mro;

crates/ty_python_semantic/src/types/ide_support.rs renamed to crates/ty_python_semantic/src/types/all_members.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
//! Routines to gather all members of a type.
2+
//!
3+
//! This is used in autocompletion logic from the `ty_ide` crate,
4+
//! but it is also used in the `ty_python_semantic` crate to provide
5+
//! "Did you mean...?" suggestions in diagnostics.
6+
17
use crate::Db;
28
use crate::place::{imported_symbol, place_from_bindings, place_from_declarations};
39
use crate::semantic_index::place::ScopeId;

crates/ty_python_semantic/src/types/call/bind.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use crate::types::signatures::{Parameter, ParameterForm};
2727
use crate::types::{
2828
BoundMethodType, ClassLiteral, DataclassParams, KnownClass, KnownInstanceType,
2929
MethodWrapperKind, PropertyInstanceType, SpecialFormType, TupleType, TypeMapping, UnionType,
30-
WrapperDescriptorKind, ide_support, todo_type,
30+
WrapperDescriptorKind, all_members, todo_type,
3131
};
3232
use ruff_db::diagnostic::{Annotation, Diagnostic, Severity, SubDiagnostic};
3333
use ruff_python_ast as ast;
@@ -669,7 +669,7 @@ impl<'db> Bindings<'db> {
669669
if let [Some(ty)] = overload.parameter_types() {
670670
overload.set_return_type(TupleType::from_elements(
671671
db,
672-
ide_support::all_members(db, *ty)
672+
all_members::all_members(db, *ty)
673673
.into_iter()
674674
.sorted()
675675
.map(|member| Type::string_literal(db, &member)),

0 commit comments

Comments
 (0)