Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ed75004
Basic implementation of Levenshtein
AlexWaygood Jun 13, 2025
71c04cf
more tests, attempt to port CPython's implementation
AlexWaygood Jun 13, 2025
305866c
get everything compiling and module tests passing
ntBre Jun 13, 2025
c1e6488
accept snapshot that Python doesn't give a suggestion for either
ntBre Jun 13, 2025
0b668d7
accept fixed suggestion
ntBre Jun 13, 2025
f859ed0
add expected error annotation
ntBre Jun 13, 2025
b12cf86
Move to submodule and fix a few nits
AlexWaygood Jun 13, 2025
dfe7353
Add suggestions for unresolved attributes too
AlexWaygood Jun 13, 2025
2e0d3be
Port more tests
AlexWaygood Jun 13, 2025
f8291c2
fix typos introduced when porting tests
AlexWaygood Jun 14, 2025
9bffbd1
Use `test_case` for other unit tests too
AlexWaygood Jun 14, 2025
ab38f8f
use annotation message for both diagnostics
AlexWaygood Jun 14, 2025
1188c55
minor cleanups
AlexWaygood Jun 15, 2025
3ee7125
More tests, fix tests, rename `ide_support` module
AlexWaygood Jun 16, 2025
e0e7a4a
apply CPython's handling of suggestions that start with underscores
AlexWaygood Jun 16, 2025
a5adbaa
run pre-commit
AlexWaygood Jun 16, 2025
4d357ea
Merge branch 'main' into alex-brent/did-you-mean
AlexWaygood Jun 16, 2025
ab16e70
update snapshots
AlexWaygood Jun 16, 2025
8b0b5bc
cargo dev generate-all
AlexWaygood Jun 16, 2025
f7cdb88
fix checkout on Windows
AlexWaygood Jun 16, 2025
067300e
Merge branch 'main' into alex-brent/did-you-mean
AlexWaygood Jun 16, 2025
7633862
Merge branch 'main' into alex-brent/did-you-mean
AlexWaygood Jun 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ extend-exclude = [
# words naturally. It's annoying to have to make all
# of them actually words. So just ignore typos here.
"crates/ty_ide/src/completion.rs",
# Same for "Did you mean...?" levenshtein tests.
"crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs",
]

[default.extend-words]
Expand Down
112 changes: 56 additions & 56 deletions crates/ty/docs/rules.md

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2167,6 +2167,57 @@ reveal_type(Foo.BAR.value) # revealed: @Todo(Attribute access on enum classes)
reveal_type(Foo.__members__) # revealed: @Todo(Attribute access on enum classes)
```

## Suggestions for obvious typos

<!-- snapshot-diagnostics -->

For obvious typos, we add a "Did you mean...?" suggestion to the diagnostic.

```py
import collections

print(collections.dequee) # error: [unresolved-attribute]
```

But the suggestion is suppressed if the only close matches start with a leading underscore:

```py
class Foo:
_bar = 42

print(Foo.bar) # error: [unresolved-attribute]
```

The suggestion is not suppressed if the typo itself starts with a leading underscore, however:

```py
print(Foo._barr) # error: [unresolved-attribute]
```

And in method contexts, the suggestion is never suppressed if accessing an attribute on an instance
of the method's enclosing class:

```py
class Bar:
_attribute = 42

def f(self, x: "Bar"):
# TODO: we should emit `[unresolved-attribute]` here, should have the same behaviour as `x.attribute` below
print(self.attribute)

# We give a suggestion here, even though the only good candidates start with underscores and the typo does not,
# because we're in a method context and `x` is an instance of the enclosing class.
print(x.attribute) # error: [unresolved-attribute]

class Baz:
def f(self, x: Bar):
# No suggestion is given here, because:
# - the good suggestions all start with underscores
# - the typo does not start with an underscore
# - We *are* in a method context, but `x` is not an instance of the enclosing class
print(x.attribute) # error: [unresolved-attribute]
```

## References

Some of the tests in the *Class and instance variables* section draw inspiration from
Expand Down
36 changes: 36 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/import/basic.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,39 @@ python-version = "3.13"
import aifc # error: [unresolved-import]
from distutils import sysconfig # error: [unresolved-import]
```

## `from` import that has a typo

We offer a "Did you mean?" subdiagnostic suggestion if there's a name in the module that's
reasonably similar to the unresolved member.

<!-- snapshot-diagnostics -->

`foo.py`:

```py
from collections import dequee # error: [unresolved-import]
```

However, we suppress the suggestion if the only close matches in the module start with a leading
underscore:

`bar.py`:

```py
from baz import foo # error: [unresolved-import]
```

`baz.py`:

```py
_foo = 42
```

The suggestion is never suppressed if the typo itself starts with a leading underscore, however:

`eggs.py`:

```py
from baz import _fooo # error: [unresolved-import]
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: attributes.md - Attributes - Suggestions for obvious typos
mdtest path: crates/ty_python_semantic/resources/mdtest/attributes.md
---

# Python source files

## mdtest_snippet.py

```
1 | import collections
2 |
3 | print(collections.dequee) # error: [unresolved-attribute]
4 | class Foo:
5 | _bar = 42
6 |
7 | print(Foo.bar) # error: [unresolved-attribute]
8 | print(Foo._barr) # error: [unresolved-attribute]
9 | class Bar:
10 | _attribute = 42
11 |
12 | def f(self, x: "Bar"):
13 | # TODO: we should emit `[unresolved-attribute]` here, should have the same behaviour as `x.attribute` below
14 | print(self.attribute)
15 |
16 | # We give a suggestion here, even though the only good candidates start with underscores and the typo does not,
17 | # because we're in a method context and `x` is an instance of the enclosing class.
18 | print(x.attribute) # error: [unresolved-attribute]
19 |
20 | class Baz:
21 | def f(self, x: Bar):
22 | # No suggestion is given here, because:
23 | # - the good suggestions all start with underscores
24 | # - the typo does not start with an underscore
25 | # - We *are* in a method context, but `x` is not an instance of the enclosing class
26 | print(x.attribute) # error: [unresolved-attribute]
```

# Diagnostics

```
error[unresolved-attribute]: Type `<module 'collections'>` has no attribute `dequee`
--> src/mdtest_snippet.py:3:7
|
1 | import collections
2 |
3 | print(collections.dequee) # error: [unresolved-attribute]
| ^^^^^^^^^^^^^^^^^^ Did you mean `deque`?
4 | class Foo:
5 | _bar = 42
|
info: rule `unresolved-attribute` is enabled by default

```

```
error[unresolved-attribute]: Type `<class 'Foo'>` has no attribute `bar`
--> src/mdtest_snippet.py:7:7
|
5 | _bar = 42
6 |
7 | print(Foo.bar) # error: [unresolved-attribute]
| ^^^^^^^
8 | print(Foo._barr) # error: [unresolved-attribute]
9 | class Bar:
|
info: rule `unresolved-attribute` is enabled by default

```

```
error[unresolved-attribute]: Type `<class 'Foo'>` has no attribute `_barr`
--> src/mdtest_snippet.py:8:7
|
7 | print(Foo.bar) # error: [unresolved-attribute]
8 | print(Foo._barr) # error: [unresolved-attribute]
| ^^^^^^^^^ Did you mean `_bar`?
9 | class Bar:
10 | _attribute = 42
|
info: rule `unresolved-attribute` is enabled by default

```

```
error[unresolved-attribute]: Type `Bar` has no attribute `attribute`
--> src/mdtest_snippet.py:18:15
|
16 | # We give a suggestion here, even though the only good candidates start with underscores and the typo does not,
17 | # because we're in a method context and `x` is an instance of the enclosing class.
18 | print(x.attribute) # error: [unresolved-attribute]
| ^^^^^^^^^^^ Did you mean `_attribute`?
19 |
20 | class Baz:
|
info: rule `unresolved-attribute` is enabled by default

```

```
error[unresolved-attribute]: Type `Bar` has no attribute `attribute`
--> src/mdtest_snippet.py:26:15
|
24 | # - the typo does not start with an underscore
25 | # - We *are* in a method context, but `x` is not an instance of the enclosing class
26 | print(x.attribute) # error: [unresolved-attribute]
| ^^^^^^^^^^^
|
info: rule `unresolved-attribute` is enabled by default

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: basic.md - Structures - `from` import that has a typo
mdtest path: crates/ty_python_semantic/resources/mdtest/import/basic.md
---

# Python source files

## foo.py

```
1 | from collections import dequee # error: [unresolved-import]
```

## bar.py

```
1 | from baz import foo # error: [unresolved-import]
```

## baz.py

```
1 | _foo = 42
```

## eggs.py

```
1 | from baz import _fooo # error: [unresolved-import]
```

# Diagnostics

```
error[unresolved-import]: Module `collections` has no member `dequee`
--> src/foo.py:1:25
|
1 | from collections import dequee # error: [unresolved-import]
| ^^^^^^ Did you mean `deque`?
|
info: rule `unresolved-import` is enabled by default

```

```
error[unresolved-import]: Module `baz` has no member `foo`
--> src/bar.py:1:17
|
1 | from baz import foo # error: [unresolved-import]
| ^^^
|
info: rule `unresolved-import` is enabled by default

```

```
error[unresolved-import]: Module `baz` has no member `_fooo`
--> src/eggs.py:1:17
|
1 | from baz import _fooo # error: [unresolved-import]
| ^^^^^ Did you mean `_foo`?
|
info: rule `unresolved-import` is enabled by default

```
2 changes: 1 addition & 1 deletion crates/ty_python_semantic/src/semantic_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::module_resolver::{Module, resolve_module};
use crate::semantic_index::ast_ids::HasScopedExpressionId;
use crate::semantic_index::place::FileScopeId;
use crate::semantic_index::semantic_index;
use crate::types::ide_support::all_declarations_and_bindings;
use crate::types::all_members::all_declarations_and_bindings;
use crate::types::{Type, binding_type, infer_scope_types};

pub struct SemanticModel<'db> {
Expand Down
4 changes: 2 additions & 2 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ use crate::semantic_index::definition::Definition;
use crate::semantic_index::place::{ScopeId, ScopedPlaceId};
use crate::semantic_index::{imported_modules, place_table, semantic_index};
use crate::suppression::check_suppressions;
pub use crate::types::all_members::all_members;
use crate::types::call::{Binding, Bindings, CallArgumentTypes, CallableBinding};
pub(crate) use crate::types::class_base::ClassBase;
use crate::types::context::{LintDiagnosticGuard, LintDiagnosticGuardBuilder};
Expand All @@ -46,7 +47,6 @@ use crate::types::function::{
DataclassTransformerParams, FunctionSpans, FunctionType, KnownFunction,
};
use crate::types::generics::{GenericContext, PartialSpecialization, Specialization};
pub use crate::types::ide_support::all_members;
use crate::types::infer::infer_unpack_types;
use crate::types::mro::{Mro, MroError, MroIterator};
pub(crate) use crate::types::narrow::infer_narrowing_constraint;
Expand All @@ -58,6 +58,7 @@ use instance::Protocol;
pub use instance::{NominalInstanceType, ProtocolInstanceType};
pub use special_form::SpecialFormType;

pub(crate) mod all_members;
mod builder;
mod call;
mod class;
Expand All @@ -67,7 +68,6 @@ mod diagnostic;
mod display;
mod function;
mod generics;
pub(crate) mod ide_support;
mod infer;
mod instance;
mod mro;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
//! Routines to gather all members of a type.
//!
//! This is used in autocompletion logic from the `ty_ide` crate,
//! but it is also used in the `ty_python_semantic` crate to provide
//! "Did you mean...?" suggestions in diagnostics.

use crate::Db;
use crate::place::{imported_symbol, place_from_bindings, place_from_declarations};
use crate::semantic_index::place::ScopeId;
Expand Down
4 changes: 2 additions & 2 deletions crates/ty_python_semantic/src/types/call/bind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use crate::types::signatures::{Parameter, ParameterForm};
use crate::types::{
BoundMethodType, ClassLiteral, DataclassParams, KnownClass, KnownInstanceType,
MethodWrapperKind, PropertyInstanceType, SpecialFormType, TupleType, TypeMapping, UnionType,
WrapperDescriptorKind, ide_support, todo_type,
WrapperDescriptorKind, all_members, todo_type,
};
use ruff_db::diagnostic::{Annotation, Diagnostic, Severity, SubDiagnostic};
use ruff_python_ast as ast;
Expand Down Expand Up @@ -667,7 +667,7 @@ impl<'db> Bindings<'db> {
if let [Some(ty)] = overload.parameter_types() {
overload.set_return_type(TupleType::from_elements(
db,
ide_support::all_members(db, *ty)
all_members::all_members(db, *ty)
.into_iter()
.sorted()
.map(|member| Type::string_literal(db, &member)),
Expand Down
4 changes: 4 additions & 0 deletions crates/ty_python_semantic/src/types/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ pub enum ClassType<'db> {

#[salsa::tracked]
impl<'db> ClassType<'db> {
pub(super) fn is_protocol(self, db: &'db dyn Db) -> bool {
self.class_literal(db).0.is_protocol(db)
}

pub(super) fn normalized(self, db: &'db dyn Db) -> Self {
match self {
Self::NonGeneric(_) => self,
Expand Down
Loading
Loading