From ed75004704dff8a9af2b59fa04a54342eb63bb78 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 13 Jun 2025 18:21:15 +0100 Subject: [PATCH 01/19] Basic implementation of Levenshtein --- .../resources/mdtest/import/basic.md | 11 +++ ...mport\342\200\246_(2fcfcf567587a056).snap" | 1 + ...hat_h\342\200\246_(3caffc60d8390adf).snap" | 30 ++++++ ...th_a_\342\200\246_(12d4a70b7fc67cc6).snap" | 1 + .../src/types/diagnostic.rs | 94 ++++++++++++++++++- crates/ty_python_semantic/src/types/infer.rs | 24 +++-- 6 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 "crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" diff --git a/crates/ty_python_semantic/resources/mdtest/import/basic.md b/crates/ty_python_semantic/resources/mdtest/import/basic.md index 8e7538190ef06..f413fe86dcde7 100644 --- a/crates/ty_python_semantic/resources/mdtest/import/basic.md +++ b/crates/ty_python_semantic/resources/mdtest/import/basic.md @@ -205,3 +205,14 @@ 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. + + + +```py +from collections import dequee +``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import\342\200\246_(2fcfcf567587a056).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import\342\200\246_(2fcfcf567587a056).snap" index dd2756ab92065..da1e6a7ca7709 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import\342\200\246_(2fcfcf567587a056).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import\342\200\246_(2fcfcf567587a056).snap" @@ -60,6 +60,7 @@ error[unresolved-import]: Module `importlib.resources` has no member `abc` | info: The stdlib module `importlib.resources` only has a `abc` submodule on Python 3.11+ info: Python 3.10 was assumed when resolving modules because it was specified on the command line +info: Did you mean `path`? info: rule `unresolved-import` is enabled by default ``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" new file mode 100644 index 0000000000000..69f2f488c5275 --- /dev/null +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" @@ -0,0 +1,30 @@ +--- +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 + +## mdtest_snippet.py + +``` +1 | from collections import dequee +``` + +# Diagnostics + +``` +error[unresolved-import]: Module `collections` has no member `dequee` + --> src/mdtest_snippet.py:1:25 + | +1 | from collections import dequee + | ^^^^^^ + | +info: Did you mean `deque`? +info: rule `unresolved-import` is enabled by default + +``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_di\342\200\246_-_Using_`from`_with_a_\342\200\246_(12d4a70b7fc67cc6).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_di\342\200\246_-_Using_`from`_with_a_\342\200\246_(12d4a70b7fc67cc6).snap" index 2972b4a66cb71..e31015f2b283f 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_di\342\200\246_-_Using_`from`_with_a_\342\200\246_(12d4a70b7fc67cc6).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_di\342\200\246_-_Using_`from`_with_a_\342\200\246_(12d4a70b7fc67cc6).snap" @@ -31,6 +31,7 @@ error[unresolved-import]: Module `a` has no member `does_not_exist` 1 | from a import does_exist1, does_not_exist, does_exist2 # error: [unresolved-import] | ^^^^^^^^^^^^^^ | +info: Did you mean `does_exist1`? info: rule `unresolved-import` is enabled by default ``` diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 8bcf8a8bb09a6..c4c1ea8fcf485 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -7,13 +7,13 @@ use super::{ }; use crate::lint::{Level, LintRegistryBuilder, LintStatus}; use crate::suppression::FileSuppressionId; -use crate::types::LintDiagnosticGuard; use crate::types::function::KnownFunction; use crate::types::string_annotation::{ BYTE_STRING_TYPE_ANNOTATION, ESCAPE_CHARACTER_IN_FORWARD_ANNOTATION, FSTRING_TYPE_ANNOTATION, IMPLICIT_CONCATENATED_STRING_TYPE_ANNOTATION, INVALID_SYNTAX_IN_FORWARD_ANNOTATION, RAW_STRING_TYPE_ANNOTATION, }; +use crate::types::{LintDiagnosticGuard, all_members}; use crate::types::{SpecialFormType, Type, protocol_class::ProtocolClassLiteral}; use crate::{Db, Module, ModuleName, Program, declare_lint}; use itertools::Itertools; @@ -2136,7 +2136,7 @@ fn report_invalid_base<'ctx, 'db>( /// misconfigured their Python version. pub(super) fn hint_if_stdlib_submodule_exists_on_other_versions( db: &dyn Db, - mut diagnostic: LintDiagnosticGuard, + diagnostic: &mut LintDiagnosticGuard, full_submodule_name: &ModuleName, parent_module: &Module, ) { @@ -2171,5 +2171,93 @@ pub(super) fn hint_if_stdlib_submodule_exists_on_other_versions( version_range = version_range.diagnostic_display(), )); - add_inferred_python_version_hint_to_diagnostic(db, &mut diagnostic, "resolving modules"); + add_inferred_python_version_hint_to_diagnostic(db, diagnostic, "resolving modules"); +} + +pub(super) fn find_best_suggestion_for_unresolved_member<'db>( + db: &'db dyn Db, + obj: Type<'db>, + unresolved_member: &str, +) -> Option { + let mut best_suggestion = None; + for member in all_members(db, obj) { + let score = levenshtein(unresolved_member, &member); + let max_distance = (unresolved_member.len() + member.len() + 3) / 3; + if score > max_distance { + continue; + } + if best_suggestion + .as_ref() + .is_none_or(|(_, best_score)| &score < best_score) + { + best_suggestion = Some((member, score)); + } + } + best_suggestion.map(|(suggestion, _)| suggestion) +} + +/// Returns the [Levenshtein edit distance] between strings `string_a` and `string_b`. +/// Uses the [Wagner-Fischer algorithm] to speed up the calculation. +/// +/// [Levenshtein edit distance]: https://en.wikipedia.org/wiki/Levenshtein_distance +/// [Wagner-Fischer algorithm]: https://en.wikipedia.org/wiki/Wagner%E2%80%93Fischer_algorithm +fn levenshtein(string_a: &str, string_b: &str) -> usize { + let string_a_chars: Vec<_> = string_a.chars().collect(); + let string_b_chars: Vec<_> = string_b.chars().collect(); + + let string_a_len = string_a_chars.len(); + let string_b_len = string_b_chars.len(); + + if string_b_len == 0 { + return string_a_len; + } + + if string_a_len == 0 { + return string_b_len; + } + + let mut previous_row = vec![0; string_b_len + 1]; + let mut current_row = vec![0; string_b_len + 1]; + + // Clippy's version is much less readable here! + #[expect(clippy::needless_range_loop)] + for i in 0..=string_b_len { + previous_row[i] = i; + } + + for (i, char_a) in string_a_chars.iter().enumerate().take(string_a_len) { + current_row[0] = i + 1; + for j in 0..string_b_len { + let deletion_cost = previous_row[j + 1] + 1; + let insertion_cost = current_row[j] + 1; + let substitution_cost = if *char_a == string_b_chars[j] { + previous_row[j] + } else { + previous_row[j] + 1 + }; + current_row[j + 1] = deletion_cost.min(insertion_cost).min(substitution_cost); + } + + std::mem::swap(&mut previous_row, &mut current_row); + } + + previous_row[string_b_len] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_levenshtein() { + let tests = [ + // These are from the Wikipedia article + ("kitten", "sitting", 3), + ("uninformed", "uniformed", 1), + ("flaw", "lawn", 2), + ]; + for (a, b, want) in tests { + assert_eq!(levenshtein(a, b), want); + } + } } diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 7b3b79472f2d2..a9f660bd2a5e0 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -81,10 +81,11 @@ use crate::types::diagnostic::{ INVALID_TYPE_ALIAS_TYPE, INVALID_TYPE_FORM, INVALID_TYPE_VARIABLE_CONSTRAINTS, POSSIBLY_UNBOUND_IMPLICIT_CALL, POSSIBLY_UNBOUND_IMPORT, TypeCheckDiagnostics, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, - UNSUPPORTED_OPERATOR, report_implicit_return_type, report_invalid_arguments_to_annotated, - report_invalid_arguments_to_callable, report_invalid_assignment, - report_invalid_attribute_assignment, report_invalid_generator_function_return_type, - report_invalid_return_type, report_possibly_unbound_attribute, + UNSUPPORTED_OPERATOR, find_best_suggestion_for_unresolved_member, report_implicit_return_type, + report_invalid_arguments_to_annotated, report_invalid_arguments_to_callable, + report_invalid_assignment, report_invalid_attribute_assignment, + report_invalid_generator_function_return_type, report_invalid_return_type, + report_possibly_unbound_attribute, }; use crate::types::function::{ FunctionDecorators, FunctionLiteral, FunctionType, KnownFunction, OverloadLiteral, @@ -4342,6 +4343,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return; } + // Now we know the import cannot be resolved. Several things remain to do: + // - Add `Unknown` as the stored type for the definition. + // - Maybe: add a diagnostic. + // - If emitting a diagnostic: see if we can add helpful subdiagnostics. + self.add_unknown_declaration_with_binding(alias.into(), definition); if &alias.name == "*" { @@ -4359,18 +4365,24 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { return; }; - let diagnostic = builder.into_diagnostic(format_args!( + let mut diagnostic = builder.into_diagnostic(format_args!( "Module `{module_name}` has no member `{name}`" )); if let Some(full_submodule_name) = full_submodule_name { hint_if_stdlib_submodule_exists_on_other_versions( self.db(), - diagnostic, + &mut diagnostic, &full_submodule_name, &module, ); } + + if let Some(suggestion) = + find_best_suggestion_for_unresolved_member(self.db(), module_ty, name) + { + diagnostic.info(format_args!("Did you mean `{suggestion}`?",)); + } } fn infer_return_statement(&mut self, ret: &ast::StmtReturn) { From 71c04cff42dfd3a980c7cd1e57977ca327e3cf68 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 13 Jun 2025 19:13:09 +0100 Subject: [PATCH 02/19] more tests, attempt to port CPython's implementation --- .../src/types/diagnostic.rs | 127 ++++++++++++++---- 1 file changed, 100 insertions(+), 27 deletions(-) diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index c4c1ea8fcf485..122b5a848d39e 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -2178,9 +2178,16 @@ pub(super) fn find_best_suggestion_for_unresolved_member<'db>( db: &'db dyn Db, obj: Type<'db>, unresolved_member: &str, +) -> Option { + find_best_suggestion(all_members(db, obj), unresolved_member) +} + +fn find_best_suggestion( + options: impl IntoIterator, + unresolved_member: &str, ) -> Option { let mut best_suggestion = None; - for member in all_members(db, obj) { + for member in options { let score = levenshtein(unresolved_member, &member); let max_distance = (unresolved_member.len() + member.len() + 3) / 3; if score > max_distance { @@ -2196,58 +2203,124 @@ pub(super) fn find_best_suggestion_for_unresolved_member<'db>( best_suggestion.map(|(suggestion, _)| suggestion) } +/// Determine the "cost" of converting `string_a` to `string_b`. +fn substitution_cost(char_a: char, char_b: char) -> CharacterMatch { + if char_a == char_b { + return CharacterMatch::Exact; + } + + if char_a + .to_lowercase() + .zip(char_b.to_lowercase()) + .all(|(a, b)| a == b) + { + return CharacterMatch::CaseInsensitive; + } + + CharacterMatch::None +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum CharacterMatch { + Exact, + CaseInsensitive, + None, +} + /// Returns the [Levenshtein edit distance] between strings `string_a` and `string_b`. /// Uses the [Wagner-Fischer algorithm] to speed up the calculation. /// /// [Levenshtein edit distance]: https://en.wikipedia.org/wiki/Levenshtein_distance /// [Wagner-Fischer algorithm]: https://en.wikipedia.org/wiki/Wagner%E2%80%93Fischer_algorithm -fn levenshtein(string_a: &str, string_b: &str) -> usize { +fn levenshtein(string_a: &str, string_b: &str, max_cost: usize) -> usize { + // We decline to add a comment for a variable that has such self-evident usage. + const MOVE_COST: usize = 2; + + if string_a == string_b { + return 0; + } + let string_a_chars: Vec<_> = string_a.chars().collect(); let string_b_chars: Vec<_> = string_b.chars().collect(); - let string_a_len = string_a_chars.len(); - let string_b_len = string_b_chars.len(); + let pre = string_a_chars + .iter() + .zip(string_b_chars.iter()) + .take_while(|(a, b)| a == b) + .count(); - if string_b_len == 0 { - return string_a_len; + let post = string_a_chars + .iter() + .zip(string_b_chars.iter().rev()) + .take_while(|(a, b)| a == b) + .count(); + + let mut string_a_chars_slice = &string_a_chars[pre..post]; + let mut string_b_chars_slice = &string_b_chars[pre..post]; + + let string_a_len = string_a_chars_slice.len(); + let string_b_len = string_b_chars_slice.len(); + + if string_a_len == 0 || string_b_len == 0 { + return MOVE_COST * (string_a_len + string_b_len); } - if string_a_len == 0 { - return string_b_len; + if (string_a_len - string_b_len) * MOVE_COST > max_cost { + return max_cost + 1; } - let mut previous_row = vec![0; string_b_len + 1]; - let mut current_row = vec![0; string_b_len + 1]; + // Prefer a shorter buffer + if string_b_chars_slice.len() < string_a_chars_slice.iter().len() { + std::mem::swap(&mut string_a_chars_slice, &mut string_b_chars_slice); + } - // Clippy's version is much less readable here! - #[expect(clippy::needless_range_loop)] - for i in 0..=string_b_len { - previous_row[i] = i; + let row_len = string_a_len.min(string_b_len) + 1; + let mut row = vec![0; row_len]; + for (i, v) in (0..MOVE_COST * row_len).step_by(MOVE_COST).enumerate() { + row[i] = v; } - for (i, char_a) in string_a_chars.iter().enumerate().take(string_a_len) { - current_row[0] = i + 1; - for j in 0..string_b_len { - let deletion_cost = previous_row[j + 1] + 1; - let insertion_cost = current_row[j] + 1; - let substitution_cost = if *char_a == string_b_chars[j] { - previous_row[j] - } else { - previous_row[j] + 1 - }; - current_row[j + 1] = deletion_cost.min(insertion_cost).min(substitution_cost); + let mut result = 0; + for bindex in 0..string_b_len { + let bchar = string_b_chars_slice[bindex]; + result = bindex * MOVE_COST; + let distance = result; + let mut minimum = std::usize::MAX; + for index in 0..string_a_len { + let substitute = distance + substitution_cost(bchar, string_a_chars[index]) as usize; + let distance = row[index]; + let insert_delete =result.min(distance) + MOVE_COST; + result = insert_delete.min(substitute); + + row[index] = result; + if result < minimum { + minimum = result; + } } - std::mem::swap(&mut previous_row, &mut current_row); + if minimum > max_cost { +return max_cost + 1; + } } - previous_row[string_b_len] + result } #[cfg(test)] mod tests { use super::*; + #[test] + fn test_bad_suggestions_do_not_trigger_for_small_names() { + let candidates = ["vvv", "mom", "w", "id", "pytho"].map(ast::name::Name::from); + for name in ["b", "v", "m", "py"] { + let suggestion = find_best_suggestion(candidates.clone(), name); + if let Some(suggestion) = suggestion { + panic!("Expected no suggestions for `{name}` but `{suggestion}` was suggested"); + } + } + } + #[test] fn test_levenshtein() { let tests = [ From 305866cd45be1d6c0adf8a43421dec75e6bda61a Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Fri, 13 Jun 2025 15:30:48 -0400 Subject: [PATCH 03/19] get everything compiling and module tests passing --- .../src/types/diagnostic.rs | 81 ++++++++++++------- 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 122b5a848d39e..a62284383bd38 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -2182,22 +2182,38 @@ pub(super) fn find_best_suggestion_for_unresolved_member<'db>( find_best_suggestion(all_members(db, obj), unresolved_member) } +/// The cost of a Levenshtein insertion, deletion, or substitution. +/// +/// This is used instead of the conventional unit cost to give these differences a higher cost than +/// casing differences, which CPython assigns a cost of 1. +const MOVE_COST: usize = 2; + fn find_best_suggestion( options: impl IntoIterator, unresolved_member: &str, ) -> Option { + if unresolved_member.is_empty() { + return None; + } + let mut best_suggestion = None; for member in options { - let score = levenshtein(unresolved_member, &member); + let mut max_distance = (member.len() + unresolved_member.len() + 3) * MOVE_COST / 6; + if let Some((_, best_distance)) = best_suggestion { + if best_distance > 0 { + max_distance = max_distance.min(best_distance - 1); + } + } + let current_distance = levenshtein(unresolved_member, &member, max_distance); let max_distance = (unresolved_member.len() + member.len() + 3) / 3; - if score > max_distance { + if current_distance > max_distance { continue; } if best_suggestion .as_ref() - .is_none_or(|(_, best_score)| &score < best_score) + .is_none_or(|(_, best_score)| ¤t_distance < best_score) { - best_suggestion = Some((member, score)); + best_suggestion = Some((member, current_distance)); } } best_suggestion.map(|(suggestion, _)| suggestion) @@ -2233,9 +2249,6 @@ enum CharacterMatch { /// [Levenshtein edit distance]: https://en.wikipedia.org/wiki/Levenshtein_distance /// [Wagner-Fischer algorithm]: https://en.wikipedia.org/wiki/Wagner%E2%80%93Fischer_algorithm fn levenshtein(string_a: &str, string_b: &str, max_cost: usize) -> usize { - // We decline to add a comment for a variable that has such self-evident usage. - const MOVE_COST: usize = 2; - if string_a == string_b { return 0; } @@ -2249,47 +2262,54 @@ fn levenshtein(string_a: &str, string_b: &str, max_cost: usize) -> usize { .take_while(|(a, b)| a == b) .count(); + let string_a_chars = &string_a_chars[pre..]; + let string_b_chars = &string_b_chars[pre..]; + let post = string_a_chars .iter() + .rev() .zip(string_b_chars.iter().rev()) .take_while(|(a, b)| a == b) .count(); - let mut string_a_chars_slice = &string_a_chars[pre..post]; - let mut string_b_chars_slice = &string_b_chars[pre..post]; + let mut string_a_chars = &string_a_chars[..string_a_chars.len() - post]; + let mut string_b_chars = &string_b_chars[..string_b_chars.len() - post]; - let string_a_len = string_a_chars_slice.len(); - let string_b_len = string_b_chars_slice.len(); + let mut string_a_len = string_a_chars.len(); + let mut string_b_len = string_b_chars.len(); if string_a_len == 0 || string_b_len == 0 { return MOVE_COST * (string_a_len + string_b_len); } - if (string_a_len - string_b_len) * MOVE_COST > max_cost { - return max_cost + 1; + // Prefer a shorter buffer + if string_b_chars.len() < string_a_chars.iter().len() { + std::mem::swap(&mut string_a_chars, &mut string_b_chars); + std::mem::swap(&mut string_a_len, &mut string_b_len); } - // Prefer a shorter buffer - if string_b_chars_slice.len() < string_a_chars_slice.iter().len() { - std::mem::swap(&mut string_a_chars_slice, &mut string_b_chars_slice); + if (string_b_len - string_a_len) * MOVE_COST > max_cost { + return max_cost + 1; } - let row_len = string_a_len.min(string_b_len) + 1; - let mut row = vec![0; row_len]; - for (i, v) in (0..MOVE_COST * row_len).step_by(MOVE_COST).enumerate() { + let mut row = vec![0; string_a_len]; + for (i, v) in (MOVE_COST..MOVE_COST * (string_a_len + 1)) + .step_by(MOVE_COST) + .enumerate() + { row[i] = v; } let mut result = 0; for bindex in 0..string_b_len { - let bchar = string_b_chars_slice[bindex]; + let bchar = string_b_chars[bindex]; result = bindex * MOVE_COST; - let distance = result; + let mut distance = result; let mut minimum = std::usize::MAX; for index in 0..string_a_len { let substitute = distance + substitution_cost(bchar, string_a_chars[index]) as usize; - let distance = row[index]; - let insert_delete =result.min(distance) + MOVE_COST; + distance = row[index]; + let insert_delete = result.min(distance) + MOVE_COST; result = insert_delete.min(substitute); row[index] = result; @@ -2299,7 +2319,7 @@ fn levenshtein(string_a: &str, string_b: &str, max_cost: usize) -> usize { } if minimum > max_cost { -return max_cost + 1; + return max_cost + 1; } } @@ -2312,7 +2332,7 @@ mod tests { #[test] fn test_bad_suggestions_do_not_trigger_for_small_names() { - let candidates = ["vvv", "mom", "w", "id", "pytho"].map(ast::name::Name::from); + let candidates = ["vvv", "mom", "w", "id", "pytho"].map(ast::name::Name::from); // # spellchecker:disable-line for name in ["b", "v", "m", "py"] { let suggestion = find_best_suggestion(candidates.clone(), name); if let Some(suggestion) = suggestion { @@ -2324,13 +2344,14 @@ mod tests { #[test] fn test_levenshtein() { let tests = [ - // These are from the Wikipedia article - ("kitten", "sitting", 3), - ("uninformed", "uniformed", 1), - ("flaw", "lawn", 2), + // These are from the Levenshtein Wikipedia article, updated to match CPython's + // implementation (just doubling the score to accommodate the MOVE_COST) + ("kitten", "sitting", 6), + ("uninformed", "uniformed", 2), + ("flaw", "lawn", 4), ]; for (a, b, want) in tests { - assert_eq!(levenshtein(a, b), want); + assert_eq!(levenshtein(a, b, std::usize::MAX), want); } } } From c1e648837c211d56ef87905d327f9a510d88db5d Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Fri, 13 Jun 2025 15:48:18 -0400 Subject: [PATCH 04/19] accept snapshot that Python doesn't give a suggestion for either --- ...6_-_Using_`from`_with_a_\342\200\246_(12d4a70b7fc67cc6).snap" | 1 - 1 file changed, 1 deletion(-) diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_di\342\200\246_-_Using_`from`_with_a_\342\200\246_(12d4a70b7fc67cc6).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_di\342\200\246_-_Using_`from`_with_a_\342\200\246_(12d4a70b7fc67cc6).snap" index e31015f2b283f..2972b4a66cb71 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_di\342\200\246_-_Using_`from`_with_a_\342\200\246_(12d4a70b7fc67cc6).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_di\342\200\246_-_Using_`from`_with_a_\342\200\246_(12d4a70b7fc67cc6).snap" @@ -31,7 +31,6 @@ error[unresolved-import]: Module `a` has no member `does_not_exist` 1 | from a import does_exist1, does_not_exist, does_exist2 # error: [unresolved-import] | ^^^^^^^^^^^^^^ | -info: Did you mean `does_exist1`? info: rule `unresolved-import` is enabled by default ``` From 0b668d752ecf71cd322fcd8ce4a44d2b2622621b Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Fri, 13 Jun 2025 15:49:24 -0400 Subject: [PATCH 05/19] accept fixed suggestion --- ...s_-_Attempting_to_import\342\200\246_(2fcfcf567587a056).snap" | 1 - 1 file changed, 1 deletion(-) diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import\342\200\246_(2fcfcf567587a056).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import\342\200\246_(2fcfcf567587a056).snap" index da1e6a7ca7709..dd2756ab92065 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import\342\200\246_(2fcfcf567587a056).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Attempting_to_import\342\200\246_(2fcfcf567587a056).snap" @@ -60,7 +60,6 @@ error[unresolved-import]: Module `importlib.resources` has no member `abc` | info: The stdlib module `importlib.resources` only has a `abc` submodule on Python 3.11+ info: Python 3.10 was assumed when resolving modules because it was specified on the command line -info: Did you mean `path`? info: rule `unresolved-import` is enabled by default ``` From f859ed03086699db9d179a92a9d531ca31ce370c Mon Sep 17 00:00:00 2001 From: Brent Westbrook Date: Fri, 13 Jun 2025 15:52:14 -0400 Subject: [PATCH 06/19] add expected error annotation --- crates/ty_python_semantic/resources/mdtest/import/basic.md | 2 +- ..._`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/import/basic.md b/crates/ty_python_semantic/resources/mdtest/import/basic.md index f413fe86dcde7..4418a7691176d 100644 --- a/crates/ty_python_semantic/resources/mdtest/import/basic.md +++ b/crates/ty_python_semantic/resources/mdtest/import/basic.md @@ -214,5 +214,5 @@ reasonably similar to the unresolved member. ```py -from collections import dequee +from collections import dequee # error: [unresolved-import] ``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" index 69f2f488c5275..7944f3a90dbfd 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" @@ -12,7 +12,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/import/basic.md ## mdtest_snippet.py ``` -1 | from collections import dequee +1 | from collections import dequee # error: [unresolved-import] ``` # Diagnostics @@ -21,7 +21,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/import/basic.md error[unresolved-import]: Module `collections` has no member `dequee` --> src/mdtest_snippet.py:1:25 | -1 | from collections import dequee +1 | from collections import dequee # error: [unresolved-import] | ^^^^^^ | info: Did you mean `deque`? From b12cf86ed0f2443bf5ff810b6c4a47b2039f620f Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 13 Jun 2025 21:25:07 +0100 Subject: [PATCH 07/19] Move to submodule and fix a few nits --- .../src/types/diagnostic.rs | 187 +---------------- .../src/types/diagnostic/levenshtein.rs | 197 ++++++++++++++++++ 2 files changed, 201 insertions(+), 183 deletions(-) create mode 100644 crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index a62284383bd38..8d59b40ffe1b0 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -7,13 +7,13 @@ use super::{ }; use crate::lint::{Level, LintRegistryBuilder, LintStatus}; use crate::suppression::FileSuppressionId; +use crate::types::LintDiagnosticGuard; use crate::types::function::KnownFunction; use crate::types::string_annotation::{ BYTE_STRING_TYPE_ANNOTATION, ESCAPE_CHARACTER_IN_FORWARD_ANNOTATION, FSTRING_TYPE_ANNOTATION, IMPLICIT_CONCATENATED_STRING_TYPE_ANNOTATION, INVALID_SYNTAX_IN_FORWARD_ANNOTATION, RAW_STRING_TYPE_ANNOTATION, }; -use crate::types::{LintDiagnosticGuard, all_members}; use crate::types::{SpecialFormType, Type, protocol_class::ProtocolClassLiteral}; use crate::{Db, Module, ModuleName, Program, declare_lint}; use itertools::Itertools; @@ -22,6 +22,9 @@ use ruff_python_ast::{self as ast, AnyNodeRef}; use ruff_text_size::{Ranged, TextRange}; use rustc_hash::FxHashSet; use std::fmt::Formatter; +pub(crate) use levenshtein::find_best_suggestion_for_unresolved_member; + +mod levenshtein; /// Registers all known type check lints. pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { @@ -2173,185 +2176,3 @@ pub(super) fn hint_if_stdlib_submodule_exists_on_other_versions( add_inferred_python_version_hint_to_diagnostic(db, diagnostic, "resolving modules"); } - -pub(super) fn find_best_suggestion_for_unresolved_member<'db>( - db: &'db dyn Db, - obj: Type<'db>, - unresolved_member: &str, -) -> Option { - find_best_suggestion(all_members(db, obj), unresolved_member) -} - -/// The cost of a Levenshtein insertion, deletion, or substitution. -/// -/// This is used instead of the conventional unit cost to give these differences a higher cost than -/// casing differences, which CPython assigns a cost of 1. -const MOVE_COST: usize = 2; - -fn find_best_suggestion( - options: impl IntoIterator, - unresolved_member: &str, -) -> Option { - if unresolved_member.is_empty() { - return None; - } - - let mut best_suggestion = None; - for member in options { - let mut max_distance = (member.len() + unresolved_member.len() + 3) * MOVE_COST / 6; - if let Some((_, best_distance)) = best_suggestion { - if best_distance > 0 { - max_distance = max_distance.min(best_distance - 1); - } - } - let current_distance = levenshtein(unresolved_member, &member, max_distance); - let max_distance = (unresolved_member.len() + member.len() + 3) / 3; - if current_distance > max_distance { - continue; - } - if best_suggestion - .as_ref() - .is_none_or(|(_, best_score)| ¤t_distance < best_score) - { - best_suggestion = Some((member, current_distance)); - } - } - best_suggestion.map(|(suggestion, _)| suggestion) -} - -/// Determine the "cost" of converting `string_a` to `string_b`. -fn substitution_cost(char_a: char, char_b: char) -> CharacterMatch { - if char_a == char_b { - return CharacterMatch::Exact; - } - - if char_a - .to_lowercase() - .zip(char_b.to_lowercase()) - .all(|(a, b)| a == b) - { - return CharacterMatch::CaseInsensitive; - } - - CharacterMatch::None -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum CharacterMatch { - Exact, - CaseInsensitive, - None, -} - -/// Returns the [Levenshtein edit distance] between strings `string_a` and `string_b`. -/// Uses the [Wagner-Fischer algorithm] to speed up the calculation. -/// -/// [Levenshtein edit distance]: https://en.wikipedia.org/wiki/Levenshtein_distance -/// [Wagner-Fischer algorithm]: https://en.wikipedia.org/wiki/Wagner%E2%80%93Fischer_algorithm -fn levenshtein(string_a: &str, string_b: &str, max_cost: usize) -> usize { - if string_a == string_b { - return 0; - } - - let string_a_chars: Vec<_> = string_a.chars().collect(); - let string_b_chars: Vec<_> = string_b.chars().collect(); - - let pre = string_a_chars - .iter() - .zip(string_b_chars.iter()) - .take_while(|(a, b)| a == b) - .count(); - - let string_a_chars = &string_a_chars[pre..]; - let string_b_chars = &string_b_chars[pre..]; - - let post = string_a_chars - .iter() - .rev() - .zip(string_b_chars.iter().rev()) - .take_while(|(a, b)| a == b) - .count(); - - let mut string_a_chars = &string_a_chars[..string_a_chars.len() - post]; - let mut string_b_chars = &string_b_chars[..string_b_chars.len() - post]; - - let mut string_a_len = string_a_chars.len(); - let mut string_b_len = string_b_chars.len(); - - if string_a_len == 0 || string_b_len == 0 { - return MOVE_COST * (string_a_len + string_b_len); - } - - // Prefer a shorter buffer - if string_b_chars.len() < string_a_chars.iter().len() { - std::mem::swap(&mut string_a_chars, &mut string_b_chars); - std::mem::swap(&mut string_a_len, &mut string_b_len); - } - - if (string_b_len - string_a_len) * MOVE_COST > max_cost { - return max_cost + 1; - } - - let mut row = vec![0; string_a_len]; - for (i, v) in (MOVE_COST..MOVE_COST * (string_a_len + 1)) - .step_by(MOVE_COST) - .enumerate() - { - row[i] = v; - } - - let mut result = 0; - for bindex in 0..string_b_len { - let bchar = string_b_chars[bindex]; - result = bindex * MOVE_COST; - let mut distance = result; - let mut minimum = std::usize::MAX; - for index in 0..string_a_len { - let substitute = distance + substitution_cost(bchar, string_a_chars[index]) as usize; - distance = row[index]; - let insert_delete = result.min(distance) + MOVE_COST; - result = insert_delete.min(substitute); - - row[index] = result; - if result < minimum { - minimum = result; - } - } - - if minimum > max_cost { - return max_cost + 1; - } - } - - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_bad_suggestions_do_not_trigger_for_small_names() { - let candidates = ["vvv", "mom", "w", "id", "pytho"].map(ast::name::Name::from); // # spellchecker:disable-line - for name in ["b", "v", "m", "py"] { - let suggestion = find_best_suggestion(candidates.clone(), name); - if let Some(suggestion) = suggestion { - panic!("Expected no suggestions for `{name}` but `{suggestion}` was suggested"); - } - } - } - - #[test] - fn test_levenshtein() { - let tests = [ - // These are from the Levenshtein Wikipedia article, updated to match CPython's - // implementation (just doubling the score to accommodate the MOVE_COST) - ("kitten", "sitting", 6), - ("uninformed", "uniformed", 2), - ("flaw", "lawn", 4), - ]; - for (a, b, want) in tests { - assert_eq!(levenshtein(a, b, std::usize::MAX), want); - } - } -} diff --git a/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs new file mode 100644 index 0000000000000..dba6e34ba935e --- /dev/null +++ b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs @@ -0,0 +1,197 @@ +use crate::Db; +use crate::types::{Type, all_members}; + +use ruff_python_ast::name::Name; + +/// Given a type and an unresolved member name, find the best suggestion for a member name +/// that is similar to the unresolved member name. +/// +/// This function is used to provide suggestions for subdiagnostics attached to +/// `unresolved-attribute`, `unresolved-import`, and `unresolved-reference` diagnostics. +pub(crate) fn find_best_suggestion_for_unresolved_member<'db>( + db: &'db dyn Db, + obj: Type<'db>, + unresolved_member: &str, +) -> Option { + find_best_suggestion(all_members(db, obj), unresolved_member) +} + +/// The cost of a Levenshtein insertion, deletion, or substitution. +/// +/// This is used instead of the conventional unit cost to give these differences a higher cost than +/// casing differences, which CPython assigns a cost of 1. +const MOVE_COST: usize = 2; + +fn find_best_suggestion( + options: impl IntoIterator, + unresolved_member: &str, +) -> Option { + if unresolved_member.is_empty() { + return None; + } + + let mut best_suggestion = None; + for member in options { + let mut max_distance = (member.len() + unresolved_member.len() + 3) * MOVE_COST / 6; + if let Some((_, best_distance)) = best_suggestion { + if best_distance > 0 { + max_distance = max_distance.min(best_distance - 1); + } + } + let current_distance = levenshtein(unresolved_member, &member, max_distance); + let max_distance = (unresolved_member.len() + member.len() + 3) / 3; + if current_distance > max_distance { + continue; + } + if best_suggestion + .as_ref() + .is_none_or(|(_, best_score)| ¤t_distance < best_score) + { + best_suggestion = Some((member, current_distance)); + } + } + best_suggestion.map(|(suggestion, _)| suggestion) +} + +/// Determine the "cost" of converting `string_a` to `string_b`. +fn substitution_cost(char_a: char, char_b: char) -> CharacterMatch { + if char_a == char_b { + return CharacterMatch::Exact; + } + + if char_a + .to_lowercase() + .zip(char_b.to_lowercase()) + .all(|(a, b)| a == b) + { + return CharacterMatch::CaseInsensitive; + } + + CharacterMatch::None +} + +/// The result of comparing two characters. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum CharacterMatch { + Exact, + CaseInsensitive, + None, +} + +/// Returns the [Levenshtein edit distance] between strings `string_a` and `string_b`. +/// Uses the [Wagner-Fischer algorithm] to speed up the calculation. +/// +/// [Levenshtein edit distance]: https://en.wikipedia.org/wiki/Levenshtein_distance +/// [Wagner-Fischer algorithm]: https://en.wikipedia.org/wiki/Wagner%E2%80%93Fischer_algorithm +fn levenshtein(string_a: &str, string_b: &str, max_cost: usize) -> usize { + if string_a == string_b { + return 0; + } + + let string_a_chars: Vec<_> = string_a.chars().collect(); + let string_b_chars: Vec<_> = string_b.chars().collect(); + + let pre = string_a_chars + .iter() + .zip(string_b_chars.iter()) + .take_while(|(a, b)| a == b) + .count(); + + let string_a_chars = &string_a_chars[pre..]; + let string_b_chars = &string_b_chars[pre..]; + + let post = string_a_chars + .iter() + .rev() + .zip(string_b_chars.iter().rev()) + .take_while(|(a, b)| a == b) + .count(); + + let mut string_a_chars = &string_a_chars[..string_a_chars.len() - post]; + let mut string_b_chars = &string_b_chars[..string_b_chars.len() - post]; + + let mut string_a_len = string_a_chars.len(); + let mut string_b_len = string_b_chars.len(); + + if string_a_len == 0 || string_b_len == 0 { + return MOVE_COST * (string_a_len + string_b_len); + } + + // Prefer a shorter buffer + if string_b_chars.len() < string_a_chars.iter().len() { + std::mem::swap(&mut string_a_chars, &mut string_b_chars); + std::mem::swap(&mut string_a_len, &mut string_b_len); + } + + if (string_b_len - string_a_len) * MOVE_COST > max_cost { + return max_cost + 1; + } + + let mut row = vec![0; string_a_len]; + for (i, v) in (MOVE_COST..MOVE_COST * (string_a_len + 1)) + .step_by(MOVE_COST) + .enumerate() + { + row[i] = v; + } + + let mut result = 0; + + for (b_index, b_char) in string_b_chars + .iter() + .copied() + .enumerate() + .take(string_b_len) + { + result = b_index * MOVE_COST; + let mut distance = result; + let mut minimum = usize::MAX; + for index in 0..string_a_len { + let substitute = distance + substitution_cost(b_char, string_a_chars[index]) as usize; + distance = row[index]; + let insert_delete = result.min(distance) + MOVE_COST; + result = insert_delete.min(substitute); + + row[index] = result; + if result < minimum { + minimum = result; + } + } + + if minimum > max_cost { + return max_cost + 1; + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bad_suggestions_do_not_trigger_for_small_names() { + let candidates = ["vvv", "mom", "w", "id", "pytho"].map(Name::from); // # spellchecker:disable-line + for name in ["b", "v", "m", "py"] { + let suggestion = find_best_suggestion(candidates.clone(), name); + if let Some(suggestion) = suggestion { + panic!("Expected no suggestions for `{name}` but `{suggestion}` was suggested"); + } + } + } + + #[test] + fn test_levenshtein() { + let tests = [ + // These are from the Levenshtein Wikipedia article, updated to match CPython's + // implementation (just doubling the score to accommodate the MOVE_COST) + ("kitten", "sitting", 6), + ("uninformed", "uniformed", 2), + ("flaw", "lawn", 4), + ]; + for (a, b, want) in tests { + assert_eq!(levenshtein(a, b, usize::MAX), want); + } + } +} From dfe7353c3f4cb9937d3f71cf2cce01a27697ef36 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 13 Jun 2025 21:36:27 +0100 Subject: [PATCH 08/19] Add suggestions for unresolved attributes too --- .../resources/mdtest/attributes.md | 12 ++++++ ..._sugg\342\200\246_(8f02422c59d4eda9).snap" | 33 ++++++++++++++ crates/ty_python_semantic/src/types/infer.rs | 43 +++++++++++-------- 3 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 "crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 71288e5109343..8ad2923edd353 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -2166,6 +2166,18 @@ reveal_type(Foo.BAR.value) # revealed: @Todo(Attribute access on enum classes) reveal_type(Foo.__members__) # revealed: @Todo(Attribute access on enum classes) ``` +## "Did you mean?" suggestions + + + +For obvious typos, we add a "Did you mean...?" suggestion to the diagnostic. + +```py +import collections + +print(collections.dequee) # error: [unresolved-attribute] +``` + ## References Some of the tests in the *Class and instance variables* section draw inspiration from diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" new file mode 100644 index 0000000000000..ad3762527ce54 --- /dev/null +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" @@ -0,0 +1,33 @@ +--- +source: crates/ty_test/src/lib.rs +expression: snapshot +--- +--- +mdtest name: attributes.md - Attributes - "Did you mean?" suggestions +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] +``` + +# Diagnostics + +``` +error[unresolved-attribute]: Type `` 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`? + | +info: rule `unresolved-attribute` is enabled by default + +``` diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index a9f660bd2a5e0..281ac7337fd41 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -6319,24 +6319,31 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { .context .report_lint(&UNRESOLVED_ATTRIBUTE, attribute) { - if bound_on_instance { - builder.into_diagnostic( - format_args!( - "Attribute `{}` can only be accessed on instances, \ - not on the class object `{}` itself.", - attr.id, - value_type.display(db) - ), - ); - } else { - builder.into_diagnostic( - format_args!( - "Type `{}` has no attribute `{}`", - value_type.display(db), - attr.id - ), - ); - } + let mut diagnostic = if bound_on_instance { + builder.into_diagnostic( + format_args!( + "Attribute `{}` can only be accessed on instances, \ + not on the class object `{}` itself.", + attr.id, + value_type.display(db) + ), + ) + } else { + builder.into_diagnostic( + format_args!( + "Type `{}` has no attribute `{}`", + value_type.display(db), + attr.id + ), + ) + }; + if let Some(suggestion) = + find_best_suggestion_for_unresolved_member(db, value_type, &attr.id) + { + diagnostic.set_primary_message(format_args!( + "Did you mean `{suggestion}`?", + )); + } } } From 2e0d3be9518dd6c2d0d33ce2ba57da0744449b84 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 13 Jun 2025 22:01:47 +0100 Subject: [PATCH 09/19] Port more tests --- .../src/types/diagnostic.rs | 2 +- .../src/types/diagnostic/levenshtein.rs | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 8d59b40ffe1b0..95072d6d20117 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -17,12 +17,12 @@ use crate::types::string_annotation::{ use crate::types::{SpecialFormType, Type, protocol_class::ProtocolClassLiteral}; use crate::{Db, Module, ModuleName, Program, declare_lint}; use itertools::Itertools; +pub(crate) use levenshtein::find_best_suggestion_for_unresolved_member; use ruff_db::diagnostic::{Annotation, Diagnostic, Severity, SubDiagnostic}; use ruff_python_ast::{self as ast, AnyNodeRef}; use ruff_text_size::{Ranged, TextRange}; use rustc_hash::FxHashSet; use std::fmt::Formatter; -pub(crate) use levenshtein::find_best_suggestion_for_unresolved_member; mod levenshtein; diff --git a/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs index dba6e34ba935e..91b22578d405e 100644 --- a/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs +++ b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs @@ -1,3 +1,11 @@ +//! Infrastructure for providing "Did you mean..?" suggestions to attach to diagnostics. +//! +//! This is a Levenshtein implementation that is mainly ported from the implementation +//! CPython uses to provide suggestions in its own exception messages. +//! The tests similarly owe much to CPython's test suite. +//! Many thanks to Pablo Galindo Salgado and others for implementing the original +//! feature in CPython! + use crate::Db; use crate::types::{Type, all_members}; @@ -169,6 +177,20 @@ fn levenshtein(string_a: &str, string_b: &str, max_cost: usize) -> usize { #[cfg(test)] mod tests { use super::*; + use test_case::test_case; + + #[test_case(&["noise", "more_noise", "a", "bc", "bluchin"], "bluch", Some("bluchin"); "test for additional characters")] + #[test_case(&["noise", "more_noise", "a", "bc", "blech"], "bluch", Some("blech"); "test for substituted characters")] + #[test_case(&["noise", "more_noise", "a", "bc", "blch"], "bluch", Some("blch"); "test for eliminated characters")] + #[test_case(&["blach", "bluc"], "bluch", Some("blach"); "substitutions are preferred over eliminations")] + #[test_case(&["blach", "bluchi"], "bluch", Some("blach"); "substitutions are preferred over additions")] + #[test_case(&["blucha", "bluc"], "bluch", Some("bluc"); "eliminations are preferred over additions")] + #[test_case(&["Lunch", "fluch", "BLunch"], "bluch", Some("BLunch"); "case changes are preferred over additions")] + fn test_good_suggestions(candidate_list: &[&str], typo: &str, expected_result: Option<&str>) { + let candidates: Vec = candidate_list.iter().copied().map(Name::from).collect(); + let suggestion = find_best_suggestion(candidates, typo); + assert_eq!(suggestion.as_deref(), expected_result); + } #[test] fn test_bad_suggestions_do_not_trigger_for_small_names() { From f8291c2be05436ec69a9e1a97ca81b8e3ce3e5db Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 14 Jun 2025 12:27:12 +0100 Subject: [PATCH 10/19] fix typos introduced when porting tests --- .../src/types/diagnostic/levenshtein.rs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs index 91b22578d405e..081f0c2925064 100644 --- a/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs +++ b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs @@ -179,17 +179,19 @@ mod tests { use super::*; use test_case::test_case; - #[test_case(&["noise", "more_noise", "a", "bc", "bluchin"], "bluch", Some("bluchin"); "test for additional characters")] - #[test_case(&["noise", "more_noise", "a", "bc", "blech"], "bluch", Some("blech"); "test for substituted characters")] - #[test_case(&["noise", "more_noise", "a", "bc", "blch"], "bluch", Some("blch"); "test for eliminated characters")] - #[test_case(&["blach", "bluc"], "bluch", Some("blach"); "substitutions are preferred over eliminations")] - #[test_case(&["blach", "bluchi"], "bluch", Some("blach"); "substitutions are preferred over additions")] - #[test_case(&["blucha", "bluc"], "bluch", Some("bluc"); "eliminations are preferred over additions")] - #[test_case(&["Lunch", "fluch", "BLunch"], "bluch", Some("BLunch"); "case changes are preferred over additions")] - fn test_good_suggestions(candidate_list: &[&str], typo: &str, expected_result: Option<&str>) { + /// Given a list of candidates, this test asserts that the best suggestion + /// for the typo `bluch` is what we'd expect. + #[test_case(&["noise", "more_noise", "a", "bc", "bluchin"], "bluchin"; "test for additional characters")] + #[test_case(&["noise", "more_noise", "a", "bc", "blech"], "blech"; "test for substituted characters")] + #[test_case(&["noise", "more_noise", "a", "bc", "blch"], "blch"; "test for eliminated characters")] + #[test_case(&["blach", "bluc"], "blach"; "substitutions are preferred over eliminations")] + #[test_case(&["blach", "bluchi"], "blach"; "substitutions are preferred over additions")] + #[test_case(&["blucha", "bluc"], "bluc"; "eliminations are preferred over additions")] + #[test_case(&["Luch", "fluch", "BLuch"], "BLuch"; "case changes are preferred over additions")] + fn test_good_suggestions(candidate_list: &[&str], expected_suggestion: &str) { let candidates: Vec = candidate_list.iter().copied().map(Name::from).collect(); - let suggestion = find_best_suggestion(candidates, typo); - assert_eq!(suggestion.as_deref(), expected_result); + let suggestion = find_best_suggestion(candidates, "bluch"); + assert_eq!(suggestion.as_deref(), Some(expected_suggestion)); } #[test] From 9bffbd1490253cdb85151c56864c52cd05c9000d Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 14 Jun 2025 12:34:32 +0100 Subject: [PATCH 11/19] Use `test_case` for other unit tests too --- _typos.toml | 2 + .../src/types/diagnostic/levenshtein.rs | 47 +++++++++++-------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/_typos.toml b/_typos.toml index 24406f10bba16..f1cd0c0ef3297 100644 --- a/_typos.toml +++ b/_typos.toml @@ -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] diff --git a/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs index 081f0c2925064..b71d24d213f13 100644 --- a/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs +++ b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs @@ -181,6 +181,8 @@ mod tests { /// Given a list of candidates, this test asserts that the best suggestion /// for the typo `bluch` is what we'd expect. + /// + /// This test is ported from #[test_case(&["noise", "more_noise", "a", "bc", "bluchin"], "bluchin"; "test for additional characters")] #[test_case(&["noise", "more_noise", "a", "bc", "blech"], "blech"; "test for substituted characters")] #[test_case(&["noise", "more_noise", "a", "bc", "blch"], "blch"; "test for eliminated characters")] @@ -194,28 +196,33 @@ mod tests { assert_eq!(suggestion.as_deref(), Some(expected_suggestion)); } - #[test] - fn test_bad_suggestions_do_not_trigger_for_small_names() { - let candidates = ["vvv", "mom", "w", "id", "pytho"].map(Name::from); // # spellchecker:disable-line - for name in ["b", "v", "m", "py"] { - let suggestion = find_best_suggestion(candidates.clone(), name); - if let Some(suggestion) = suggestion { - panic!("Expected no suggestions for `{name}` but `{suggestion}` was suggested"); - } + /// This asserts that we do not offer silly suggestions for very small names. + /// The test is ported from + #[test_case("b")] + #[test_case("v")] + #[test_case("m")] + #[test_case("py")] + fn test_bad_suggestions_do_not_trigger_for_small_names(typo: &str) { + let candidates = ["vvv", "mom", "w", "id", "pytho"].map(Name::from); + let suggestion = find_best_suggestion(candidates, typo); + if let Some(suggestion) = suggestion { + panic!("Expected no suggestions for `{typo}` but `{suggestion}` was suggested"); } } - #[test] - fn test_levenshtein() { - let tests = [ - // These are from the Levenshtein Wikipedia article, updated to match CPython's - // implementation (just doubling the score to accommodate the MOVE_COST) - ("kitten", "sitting", 6), - ("uninformed", "uniformed", 2), - ("flaw", "lawn", 4), - ]; - for (a, b, want) in tests { - assert_eq!(levenshtein(a, b, usize::MAX), want); - } + // These tests are from the Levenshtein Wikipedia article, updated to match CPython's + // implementation (just doubling the score to accommodate the MOVE_COST) + #[test_case("kitten", "sitting", 6)] + #[test_case("uninformed", "uniformed", 2)] + #[test_case("flaw", "lawn", 4)] + fn test_levenshtein_distance_calculation( + string_a: &str, + string_b: &str, + expected_distance: usize, + ) { + assert_eq!( + levenshtein(string_a, string_b, usize::MAX), + expected_distance + ); } } From ab38f8fb8ee3095e29bb78764efd9ceb43b89306 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 14 Jun 2025 12:39:24 +0100 Subject: [PATCH 12/19] use annotation message for both diagnostics --- ...-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" | 3 +-- crates/ty_python_semantic/src/types/infer.rs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" index 7944f3a90dbfd..cbbf3153c5a83 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" @@ -22,9 +22,8 @@ error[unresolved-import]: Module `collections` has no member `dequee` --> src/mdtest_snippet.py:1:25 | 1 | from collections import dequee # error: [unresolved-import] - | ^^^^^^ + | ^^^^^^ Did you mean `deque`? | -info: Did you mean `deque`? info: rule `unresolved-import` is enabled by default ``` diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 281ac7337fd41..12c81b16d62ad 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -4381,7 +4381,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if let Some(suggestion) = find_best_suggestion_for_unresolved_member(self.db(), module_ty, name) { - diagnostic.info(format_args!("Did you mean `{suggestion}`?",)); + diagnostic.set_primary_message(format_args!("Did you mean `{suggestion}`?",)); } } From 1188c55d09b62b3a8ab065cb3336dcb5e972e071 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 15 Jun 2025 14:00:48 +0100 Subject: [PATCH 13/19] minor cleanups --- .../src/types/diagnostic/levenshtein.rs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs index b71d24d213f13..9d0581613a221 100644 --- a/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs +++ b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs @@ -46,7 +46,7 @@ fn find_best_suggestion( max_distance = max_distance.min(best_distance - 1); } } - let current_distance = levenshtein(unresolved_member, &member, max_distance); + let current_distance = levenshtein_distance(unresolved_member, &member, max_distance); let max_distance = (unresolved_member.len() + member.len() + 3) / 3; if current_distance > max_distance { continue; @@ -91,7 +91,7 @@ enum CharacterMatch { /// /// [Levenshtein edit distance]: https://en.wikipedia.org/wiki/Levenshtein_distance /// [Wagner-Fischer algorithm]: https://en.wikipedia.org/wiki/Wagner%E2%80%93Fischer_algorithm -fn levenshtein(string_a: &str, string_b: &str, max_cost: usize) -> usize { +fn levenshtein_distance(string_a: &str, string_b: &str, max_cost: usize) -> usize { if string_a == string_b { return 0; } @@ -99,38 +99,41 @@ fn levenshtein(string_a: &str, string_b: &str, max_cost: usize) -> usize { let string_a_chars: Vec<_> = string_a.chars().collect(); let string_b_chars: Vec<_> = string_b.chars().collect(); + // Trim away common affixes let pre = string_a_chars .iter() .zip(string_b_chars.iter()) .take_while(|(a, b)| a == b) .count(); - let string_a_chars = &string_a_chars[pre..]; let string_b_chars = &string_b_chars[pre..]; + // Trim away common suffixes let post = string_a_chars .iter() .rev() .zip(string_b_chars.iter().rev()) .take_while(|(a, b)| a == b) .count(); - let mut string_a_chars = &string_a_chars[..string_a_chars.len() - post]; let mut string_b_chars = &string_b_chars[..string_b_chars.len() - post]; let mut string_a_len = string_a_chars.len(); let mut string_b_len = string_b_chars.len(); + // Short-circuit if either string is empty after trimming affixes/suffixes if string_a_len == 0 || string_b_len == 0 { return MOVE_COST * (string_a_len + string_b_len); } - // Prefer a shorter buffer - if string_b_chars.len() < string_a_chars.iter().len() { + // `string_a` should refer to the shorter of the two strings. + // This enables us to create a smaller buffer in the main loop below. + if string_b_chars.len() < string_a_chars.len() { std::mem::swap(&mut string_a_chars, &mut string_b_chars); std::mem::swap(&mut string_a_len, &mut string_b_len); } + // Quick fail if a match is impossible. if (string_b_len - string_a_len) * MOVE_COST > max_cost { return max_cost + 1; } @@ -189,7 +192,7 @@ mod tests { #[test_case(&["blach", "bluc"], "blach"; "substitutions are preferred over eliminations")] #[test_case(&["blach", "bluchi"], "blach"; "substitutions are preferred over additions")] #[test_case(&["blucha", "bluc"], "bluc"; "eliminations are preferred over additions")] - #[test_case(&["Luch", "fluch", "BLuch"], "BLuch"; "case changes are preferred over additions")] + #[test_case(&["Luch", "fluch", "BLuch"], "BLuch"; "case changes are preferred over substitutions")] fn test_good_suggestions(candidate_list: &[&str], expected_suggestion: &str) { let candidates: Vec = candidate_list.iter().copied().map(Name::from).collect(); let suggestion = find_best_suggestion(candidates, "bluch"); @@ -221,8 +224,13 @@ mod tests { expected_distance: usize, ) { assert_eq!( - levenshtein(string_a, string_b, usize::MAX), + levenshtein_distance(string_a, string_b, usize::MAX), expected_distance ); } + + #[test] + fn move_cost_consistent_with_character_match() { + assert_eq!(MOVE_COST, CharacterMatch::None as usize); + } } From 3ee71254ff5cf86cdb94f7c268431ebfe5c24075 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 16 Jun 2025 12:15:09 +0100 Subject: [PATCH 14/19] More tests, fix tests, rename `ide_support` module --- .../ty_python_semantic/src/semantic_model.rs | 2 +- crates/ty_python_semantic/src/types.rs | 4 +- .../types/{ide_support.rs => all_members.rs} | 6 + .../ty_python_semantic/src/types/call/bind.rs | 4 +- .../src/types/diagnostic/levenshtein.rs | 107 ++++++++++++++---- 5 files changed, 94 insertions(+), 29 deletions(-) rename crates/ty_python_semantic/src/types/{ide_support.rs => all_members.rs} (96%) diff --git a/crates/ty_python_semantic/src/semantic_model.rs b/crates/ty_python_semantic/src/semantic_model.rs index 9237e75ee2ece..e174010fe828a 100644 --- a/crates/ty_python_semantic/src/semantic_model.rs +++ b/crates/ty_python_semantic/src/semantic_model.rs @@ -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> { diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 94dbe1be273c3..1cebcfdd1bc4b 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -46,7 +46,7 @@ use crate::types::function::{ DataclassTransformerParams, FunctionSpans, FunctionType, KnownFunction, }; use crate::types::generics::{GenericContext, PartialSpecialization, Specialization}; -pub use crate::types::ide_support::all_members; +pub use crate::types::all_members::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; @@ -67,7 +67,7 @@ mod diagnostic; mod display; mod function; mod generics; -pub(crate) mod ide_support; +pub(crate) mod all_members; mod infer; mod instance; mod mro; diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/all_members.rs similarity index 96% rename from crates/ty_python_semantic/src/types/ide_support.rs rename to crates/ty_python_semantic/src/types/all_members.rs index b79d232c70737..d93b9ce1669d5 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/all_members.rs @@ -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; diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 45f7d5694da00..322abf83e28f2 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -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; @@ -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)), diff --git a/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs index 9d0581613a221..4810c56c96403 100644 --- a/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs +++ b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs @@ -9,6 +9,7 @@ use crate::Db; use crate::types::{Type, all_members}; +use indexmap::IndexSet; use ruff_python_ast::name::Name; /// Given a type and an unresolved member name, find the best suggestion for a member name @@ -24,33 +25,46 @@ pub(crate) fn find_best_suggestion_for_unresolved_member<'db>( find_best_suggestion(all_members(db, obj), unresolved_member) } -/// The cost of a Levenshtein insertion, deletion, or substitution. -/// -/// This is used instead of the conventional unit cost to give these differences a higher cost than -/// casing differences, which CPython assigns a cost of 1. -const MOVE_COST: usize = 2; - -fn find_best_suggestion( - options: impl IntoIterator, - unresolved_member: &str, -) -> Option { +fn find_best_suggestion(options: O, unresolved_member: &str) -> Option +where + O: IntoIterator, + I: ExactSizeIterator, +{ if unresolved_member.is_empty() { return None; } + let options = options.into_iter(); + + // Don't spend a *huge* amount of time computing suggestions if there are many candidates. + // This limit is fairly arbitrary and can be adjusted as needed. + if options.len() > 4096 { + return None; + } + + let mut options: IndexSet = options.collect(); + options.sort_unstable(); + find_best_suggestion_impl(options, unresolved_member) +} + +fn find_best_suggestion_impl(options: IndexSet, unresolved_member: &str) -> Option { let mut best_suggestion = None; + for member in options { - let mut max_distance = (member.len() + unresolved_member.len() + 3) * MOVE_COST / 6; + let mut max_distance = + (member.chars().count() + unresolved_member.chars().count() + 3) * MOVE_COST / 6; + if let Some((_, best_distance)) = best_suggestion { if best_distance > 0 { max_distance = max_distance.min(best_distance - 1); } } + let current_distance = levenshtein_distance(unresolved_member, &member, max_distance); - let max_distance = (unresolved_member.len() + member.len() + 3) / 3; if current_distance > max_distance { continue; } + if best_suggestion .as_ref() .is_none_or(|(_, best_score)| ¤t_distance < best_score) @@ -58,6 +72,7 @@ fn find_best_suggestion( best_suggestion = Some((member, current_distance)); } } + best_suggestion.map(|(suggestion, _)| suggestion) } @@ -67,10 +82,11 @@ fn substitution_cost(char_a: char, char_b: char) -> CharacterMatch { return CharacterMatch::Exact; } - if char_a - .to_lowercase() - .zip(char_b.to_lowercase()) - .all(|(a, b)| a == b) + let char_a_lowercase = char_a.to_lowercase(); + let char_b_lowercase = char_b.to_lowercase(); + + if char_a_lowercase.len() == char_b_lowercase.len() + && char_a_lowercase.zip(char_b_lowercase).all(|(a, b)| a == b) { return CharacterMatch::CaseInsensitive; } @@ -86,6 +102,13 @@ enum CharacterMatch { None, } +/// The cost of a Levenshtein insertion, deletion, or substitution. +/// It should be the same as `CharacterMatch::None` cast to a `usize`. +/// +/// This is used instead of the conventional unit cost to give these differences a higher cost than +/// casing differences, which CPython assigns a cost of 1. +const MOVE_COST: usize = CharacterMatch::None as usize; + /// Returns the [Levenshtein edit distance] between strings `string_a` and `string_b`. /// Uses the [Wagner-Fischer algorithm] to speed up the calculation. /// @@ -96,8 +119,8 @@ fn levenshtein_distance(string_a: &str, string_b: &str, max_cost: usize) -> usiz return 0; } - let string_a_chars: Vec<_> = string_a.chars().collect(); - let string_b_chars: Vec<_> = string_b.chars().collect(); + let string_a_chars: Vec = string_a.chars().collect(); + let string_b_chars: Vec = string_b.chars().collect(); // Trim away common affixes let pre = string_a_chars @@ -213,12 +236,21 @@ mod tests { } } - // These tests are from the Levenshtein Wikipedia article, updated to match CPython's - // implementation (just doubling the score to accommodate the MOVE_COST) + /// Test ported from + #[test] + fn test_no_suggestion_for_very_different_attribute() { + assert_eq!( + find_best_suggestion([Name::from("blech")], "somethingverywrong"), + None + ); + } + + /// These tests are from the Levenshtein Wikipedia article, updated to match CPython's + /// implementation (just doubling the score to accommodate the MOVE_COST) #[test_case("kitten", "sitting", 6)] #[test_case("uninformed", "uniformed", 2)] #[test_case("flaw", "lawn", 4)] - fn test_levenshtein_distance_calculation( + fn test_levenshtein_distance_calculation_wikipedia_examples( string_a: &str, string_b: &str, expected_distance: usize, @@ -229,8 +261,35 @@ mod tests { ); } - #[test] - fn move_cost_consistent_with_character_match() { - assert_eq!(MOVE_COST, CharacterMatch::None as usize); + /// Test ported from + #[test_case("", "", 0)] + #[test_case("", "a", 2)] + #[test_case("a", "A", 1)] + #[test_case("Apple", "Aple", 2)] + #[test_case("Banana", "B@n@n@", 6)] + #[test_case("Cherry", "Cherry!", 2)] + #[test_case("---0---", "------", 2)] + #[test_case("abc", "y", 6)] + #[test_case("aa", "bb", 4)] + #[test_case("aaaaa", "AAAAA", 5)] + #[test_case("wxyz", "wXyZ", 2)] + #[test_case("wxyz", "wXyZ123", 8)] + #[test_case("Python", "Java", 12)] + #[test_case("Java", "C#", 8)] + #[test_case("AbstractFoobarManager", "abstract_foobar_manager", 3+2*2)] + #[test_case("CPython", "PyPy", 10)] + #[test_case("CPython", "pypy", 11)] + #[test_case("AttributeError", "AttributeErrop", 2)] + #[test_case("AttributeError", "AttributeErrorTests", 10)] + #[test_case("ABA", "AAB", 4)] + fn test_levenshtein_distance_calculation_cpython_examples( + string_a: &str, + string_b: &str, + expected_distance: usize, + ) { + assert_eq!( + levenshtein_distance(string_a, string_b, 4044), + expected_distance + ); } } From e0e7a4aa6d68faa3bafe027f3b869b1cbce75af4 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 16 Jun 2025 13:14:44 +0100 Subject: [PATCH 15/19] apply CPython's handling of suggestions that start with underscores --- .../resources/mdtest/attributes.md | 40 ++++++++ .../resources/mdtest/import/basic.md | 25 +++++ ..._sugg\342\200\246_(8f02422c59d4eda9).snap" | 87 ++++++++++++++++- ...hat_h\342\200\246_(3caffc60d8390adf).snap" | 44 ++++++++- crates/ty_python_semantic/src/types/class.rs | 4 + .../src/types/diagnostic.rs | 4 +- .../src/types/diagnostic/levenshtein.rs | 95 +++++++++++++++++-- crates/ty_python_semantic/src/types/infer.rs | 51 ++++++---- 8 files changed, 319 insertions(+), 31 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 8ad2923edd353..e190856293766 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -2178,6 +2178,46 @@ 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 diff --git a/crates/ty_python_semantic/resources/mdtest/import/basic.md b/crates/ty_python_semantic/resources/mdtest/import/basic.md index 4418a7691176d..01d8045b97ed1 100644 --- a/crates/ty_python_semantic/resources/mdtest/import/basic.md +++ b/crates/ty_python_semantic/resources/mdtest/import/basic.md @@ -213,6 +213,31 @@ reasonably similar to the unresolved member. +`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] +``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" index ad3762527ce54..931f440dfde65 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" @@ -12,9 +12,33 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/attributes.md ## mdtest_snippet.py ``` -1 | import collections -2 | -3 | print(collections.dequee) # error: [unresolved-attribute] + 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 | +21 | class Baz: +22 | def f(self, x: Bar): +23 | # No suggestion is given here, because: +24 | # - the good suggestions all start with underscores +25 | # - the typo does not start with an underscore +26 | # - We *are* in a method context, but `x` is not an instance of the enclosing class +27 | print(x.attribute) # error: [unresolved-attribute] ``` # Diagnostics @@ -27,7 +51,64 @@ error[unresolved-attribute]: Type `` has no attribute `deq 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 `` 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 `` 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`? + | +info: rule `unresolved-attribute` is enabled by default + +``` + +``` +error[unresolved-attribute]: Type `Bar` has no attribute `attribute` + --> src/mdtest_snippet.py:27:15 + | +25 | # - the typo does not start with an underscore +26 | # - We *are* in a method context, but `x` is not an instance of the enclosing class +27 | print(x.attribute) # error: [unresolved-attribute] + | ^^^^^^^^^^^ + | +info: rule `unresolved-attribute` is enabled by default + +``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" index cbbf3153c5a83..0b237ac4def49 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_`from`_import_that_h\342\200\246_(3caffc60d8390adf).snap" @@ -9,17 +9,35 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/import/basic.md # Python source files -## mdtest_snippet.py +## 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/mdtest_snippet.py:1:25 + --> src/foo.py:1:25 | 1 | from collections import dequee # error: [unresolved-import] | ^^^^^^ Did you mean `deque`? @@ -27,3 +45,25 @@ error[unresolved-import]: Module `collections` has no member `dequee` 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 + +``` diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 78532e12de12b..358fcd7b98818 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -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, diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 95072d6d20117..a4a3add5f2d9d 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -17,7 +17,9 @@ use crate::types::string_annotation::{ use crate::types::{SpecialFormType, Type, protocol_class::ProtocolClassLiteral}; use crate::{Db, Module, ModuleName, Program, declare_lint}; use itertools::Itertools; -pub(crate) use levenshtein::find_best_suggestion_for_unresolved_member; +pub(crate) use levenshtein::{ + HideUnderscoredSuggestions, find_best_suggestion_for_unresolved_member, +}; use ruff_db::diagnostic::{Annotation, Diagnostic, Severity, SubDiagnostic}; use ruff_python_ast::{self as ast, AnyNodeRef}; use ruff_text_size::{Ranged, TextRange}; diff --git a/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs index 4810c56c96403..11fc59674e0d3 100644 --- a/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs +++ b/crates/ty_python_semantic/src/types/diagnostic/levenshtein.rs @@ -21,11 +21,35 @@ pub(crate) fn find_best_suggestion_for_unresolved_member<'db>( db: &'db dyn Db, obj: Type<'db>, unresolved_member: &str, + hide_underscored_suggestions: HideUnderscoredSuggestions, ) -> Option { - find_best_suggestion(all_members(db, obj), unresolved_member) + find_best_suggestion( + all_members(db, obj), + unresolved_member, + hide_underscored_suggestions, + ) } -fn find_best_suggestion(options: O, unresolved_member: &str) -> Option +/// Whether to hide suggestions that start with an underscore. +/// +/// If the typo itself starts with an underscore, this policy is ignored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HideUnderscoredSuggestions { + Yes, + No, +} + +impl HideUnderscoredSuggestions { + const fn is_no(self) -> bool { + matches!(self, HideUnderscoredSuggestions::No) + } +} + +fn find_best_suggestion( + options: O, + unresolved_member: &str, + hide_underscored_suggestions: HideUnderscoredSuggestions, +) -> Option where O: IntoIterator, I: ExactSizeIterator, @@ -42,7 +66,24 @@ where return None; } - let mut options: IndexSet = options.collect(); + // Filter out the unresolved member itself. + // Otherwise (due to our implementation of implicit instance attributes), + // we end up giving bogus suggestions like this: + // + // ```python + // class Foo: + // _attribute = 42 + // def bar(self): + // print(self.attribute) # error: unresolved attribute `attribute`; did you mean `attribute`? + // ``` + let options = options.filter(|name| name != unresolved_member); + + let mut options: IndexSet = + if hide_underscored_suggestions.is_no() || unresolved_member.starts_with('_') { + options.collect() + } else { + options.filter(|name| !name.starts_with('_')).collect() + }; options.sort_unstable(); find_best_suggestion_impl(options, unresolved_member) } @@ -218,10 +259,48 @@ mod tests { #[test_case(&["Luch", "fluch", "BLuch"], "BLuch"; "case changes are preferred over substitutions")] fn test_good_suggestions(candidate_list: &[&str], expected_suggestion: &str) { let candidates: Vec = candidate_list.iter().copied().map(Name::from).collect(); - let suggestion = find_best_suggestion(candidates, "bluch"); + let suggestion = find_best_suggestion(candidates, "bluch", HideUnderscoredSuggestions::No); assert_eq!(suggestion.as_deref(), Some(expected_suggestion)); } + /// Test ported from + #[test] + fn underscored_names_not_suggested_if_hide_policy_set_to_yes() { + let suggestion = find_best_suggestion( + [Name::from("_bluch")], + "bluch", + HideUnderscoredSuggestions::Yes, + ); + if let Some(suggestion) = suggestion { + panic!( + "Expected no suggestions for `bluch` due to `HideUnderscoredSuggestions::Yes` but `{suggestion}` was suggested" + ); + } + } + + /// Test ported from + #[test_case("_blach")] + #[test_case("_luch")] + fn underscored_names_are_suggested_if_hide_policy_set_to_yes_when_typo_is_underscored( + typo: &str, + ) { + let suggestion = find_best_suggestion( + [Name::from("_bluch")], + typo, + HideUnderscoredSuggestions::Yes, + ); + assert_eq!(suggestion.as_deref(), Some("_bluch")); + } + + /// Test ported from + #[test_case("_luch")] + #[test_case("_bluch")] + fn non_underscored_names_always_suggested_even_if_typo_underscored(typo: &str) { + let suggestion = + find_best_suggestion([Name::from("bluch")], typo, HideUnderscoredSuggestions::Yes); + assert_eq!(suggestion.as_deref(), Some("bluch")); + } + /// This asserts that we do not offer silly suggestions for very small names. /// The test is ported from #[test_case("b")] @@ -230,7 +309,7 @@ mod tests { #[test_case("py")] fn test_bad_suggestions_do_not_trigger_for_small_names(typo: &str) { let candidates = ["vvv", "mom", "w", "id", "pytho"].map(Name::from); - let suggestion = find_best_suggestion(candidates, typo); + let suggestion = find_best_suggestion(candidates, typo, HideUnderscoredSuggestions::No); if let Some(suggestion) = suggestion { panic!("Expected no suggestions for `{typo}` but `{suggestion}` was suggested"); } @@ -240,7 +319,11 @@ mod tests { #[test] fn test_no_suggestion_for_very_different_attribute() { assert_eq!( - find_best_suggestion([Name::from("blech")], "somethingverywrong"), + find_best_suggestion( + [Name::from("blech")], + "somethingverywrong", + HideUnderscoredSuggestions::No + ), None ); } diff --git a/crates/ty_python_semantic/src/types/infer.rs b/crates/ty_python_semantic/src/types/infer.rs index 12c81b16d62ad..4e819fe2771a2 100644 --- a/crates/ty_python_semantic/src/types/infer.rs +++ b/crates/ty_python_semantic/src/types/infer.rs @@ -75,17 +75,17 @@ use crate::types::call::{ use crate::types::class::{MetaclassErrorKind, SliceLiteral}; use crate::types::diagnostic::{ self, CALL_NON_CALLABLE, CONFLICTING_DECLARATIONS, CONFLICTING_METACLASS, - CYCLIC_CLASS_DEFINITION, DIVISION_BY_ZERO, INCONSISTENT_MRO, INVALID_ARGUMENT_TYPE, - INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS, INVALID_BASE, INVALID_DECLARATION, - INVALID_GENERIC_CLASS, INVALID_LEGACY_TYPE_VARIABLE, INVALID_PARAMETER_DEFAULT, - INVALID_TYPE_ALIAS_TYPE, INVALID_TYPE_FORM, INVALID_TYPE_VARIABLE_CONSTRAINTS, - POSSIBLY_UNBOUND_IMPLICIT_CALL, POSSIBLY_UNBOUND_IMPORT, TypeCheckDiagnostics, - UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_IMPORT, UNRESOLVED_REFERENCE, - UNSUPPORTED_OPERATOR, find_best_suggestion_for_unresolved_member, report_implicit_return_type, - report_invalid_arguments_to_annotated, report_invalid_arguments_to_callable, - report_invalid_assignment, report_invalid_attribute_assignment, - report_invalid_generator_function_return_type, report_invalid_return_type, - report_possibly_unbound_attribute, + CYCLIC_CLASS_DEFINITION, DIVISION_BY_ZERO, HideUnderscoredSuggestions, INCONSISTENT_MRO, + INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS, INVALID_BASE, + INVALID_DECLARATION, INVALID_GENERIC_CLASS, INVALID_LEGACY_TYPE_VARIABLE, + INVALID_PARAMETER_DEFAULT, INVALID_TYPE_ALIAS_TYPE, INVALID_TYPE_FORM, + INVALID_TYPE_VARIABLE_CONSTRAINTS, POSSIBLY_UNBOUND_IMPLICIT_CALL, POSSIBLY_UNBOUND_IMPORT, + TypeCheckDiagnostics, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_IMPORT, + UNRESOLVED_REFERENCE, UNSUPPORTED_OPERATOR, find_best_suggestion_for_unresolved_member, + report_implicit_return_type, report_invalid_arguments_to_annotated, + report_invalid_arguments_to_callable, report_invalid_assignment, + report_invalid_attribute_assignment, report_invalid_generator_function_return_type, + report_invalid_return_type, report_possibly_unbound_attribute, }; use crate::types::function::{ FunctionDecorators, FunctionLiteral, FunctionType, KnownFunction, OverloadLiteral, @@ -1793,7 +1793,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// is a class scope OR the immediate parent scope is an annotation scope /// and the grandparent scope is a class scope. This means it has different /// behaviour to the [`nearest_enclosing_class`] function. - fn class_context_of_current_method(&self) -> Option> { + fn class_context_of_current_method(&self) -> Option> { let current_scope_id = self.scope().file_scope_id(self.db()); let current_scope = self.index.scope(current_scope_id); if current_scope.kind() != ScopeKind::Function { @@ -1818,7 +1818,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let class_stmt = class_scope.node().as_class(self.module())?; let class_definition = self.index.expect_single_definition(class_stmt); - binding_type(self.db(), class_definition).into_class_literal() + binding_type(self.db(), class_definition).to_class_type(self.db()) } /// Returns `true` if the current scope is the function body scope of a function overload (that @@ -1968,7 +1968,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { returns.range(), declared_ty, has_empty_body, - enclosing_class_context, + enclosing_class_context.map(|class| class.class_literal(self.db()).0), no_return, ); } @@ -4378,9 +4378,12 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ); } - if let Some(suggestion) = - find_best_suggestion_for_unresolved_member(self.db(), module_ty, name) - { + if let Some(suggestion) = find_best_suggestion_for_unresolved_member( + self.db(), + module_ty, + name, + HideUnderscoredSuggestions::Yes, + ) { diagnostic.set_primary_message(format_args!("Did you mean `{suggestion}`?",)); } } @@ -6234,7 +6237,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let attribute_exists = self .class_context_of_current_method() .and_then(|class| { - Type::instance(self.db(), class.default_specialization(self.db())) + Type::instance(self.db(), class) .member(self.db(), id) .place .ignore_possibly_unbound() @@ -6337,8 +6340,18 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ), ) }; + + let underscore_policy = if self + .class_context_of_current_method() + .is_some_and(|class|value_type.is_subtype_of(db, Type::instance(db, class))) + { + HideUnderscoredSuggestions::No + } else { + HideUnderscoredSuggestions::Yes + }; + if let Some(suggestion) = - find_best_suggestion_for_unresolved_member(db, value_type, &attr.id) + find_best_suggestion_for_unresolved_member(db, value_type, &attr.id, underscore_policy) { diagnostic.set_primary_message(format_args!( "Did you mean `{suggestion}`?", From a5adbaaba3eed92a2935c9b7072a3883dc6880f3 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 16 Jun 2025 13:16:20 +0100 Subject: [PATCH 16/19] run pre-commit --- crates/ty_python_semantic/resources/mdtest/attributes.md | 1 - crates/ty_python_semantic/src/types.rs | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index e190856293766..0273642e35ce5 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -2208,7 +2208,6 @@ class Bar: # 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: diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 1cebcfdd1bc4b..a864fdd2629fd 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -38,6 +38,7 @@ use crate::semantic_index::definition::Definition; use crate::semantic_index::place::ScopeId; use crate::semantic_index::{imported_modules, 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}; @@ -46,7 +47,6 @@ use crate::types::function::{ DataclassTransformerParams, FunctionSpans, FunctionType, KnownFunction, }; use crate::types::generics::{GenericContext, PartialSpecialization, Specialization}; -pub use crate::types::all_members::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; @@ -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; @@ -67,7 +68,6 @@ mod diagnostic; mod display; mod function; mod generics; -pub(crate) mod all_members; mod infer; mod instance; mod mro; From ab16e7000a8491143a18f075881f5fc530131528 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 16 Jun 2025 13:21:50 +0100 Subject: [PATCH 17/19] update snapshots --- ..._sugg\342\200\246_(8f02422c59d4eda9).snap" | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" index 931f440dfde65..a4ebb1d57f9ba 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" @@ -31,14 +31,13 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/attributes.md 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 | -21 | class Baz: -22 | def f(self, x: Bar): -23 | # No suggestion is given here, because: -24 | # - the good suggestions all start with underscores -25 | # - the typo does not start with an underscore -26 | # - We *are* in a method context, but `x` is not an instance of the enclosing class -27 | print(x.attribute) # error: [unresolved-attribute] +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 @@ -95,6 +94,8 @@ error[unresolved-attribute]: Type `Bar` has no attribute `attribute` 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 @@ -102,11 +103,11 @@ info: rule `unresolved-attribute` is enabled by default ``` error[unresolved-attribute]: Type `Bar` has no attribute `attribute` - --> src/mdtest_snippet.py:27:15 + --> src/mdtest_snippet.py:26:15 | -25 | # - the typo does not start with an underscore -26 | # - We *are* in a method context, but `x` is not an instance of the enclosing class -27 | print(x.attribute) # error: [unresolved-attribute] +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 From 8b0b5bcd5329dfca0fd3511ced3453a346b8b924 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 16 Jun 2025 13:27:58 +0100 Subject: [PATCH 18/19] cargo dev generate-all --- crates/ty/docs/rules.md | 112 ++++++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index b36ccae6576be..740f5acb08877 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -52,7 +52,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20call-non-callable) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L94) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L99) ## `conflicting-argument-forms` @@ -83,7 +83,7 @@ f(int) # error ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20conflicting-argument-forms) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L138) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L143) ## `conflicting-declarations` @@ -113,7 +113,7 @@ a = 1 ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20conflicting-declarations) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L164) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L169) ## `conflicting-metaclass` @@ -144,7 +144,7 @@ class C(A, B): ... ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20conflicting-metaclass) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L189) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L194) ## `cyclic-class-definition` @@ -175,7 +175,7 @@ class B(A): ... ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20cyclic-class-definition) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L215) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L220) ## `duplicate-base` @@ -201,7 +201,7 @@ class B(A, A): ... ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20duplicate-base) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L259) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L264) ## `escape-character-in-forward-annotation` @@ -338,7 +338,7 @@ TypeError: multiple bases have instance lay-out conflict ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20incompatible-slots) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L280) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L285) ## `inconsistent-mro` @@ -367,7 +367,7 @@ class C(A, B): ... ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20inconsistent-mro) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L366) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L371) ## `index-out-of-bounds` @@ -392,7 +392,7 @@ t[3] # IndexError: tuple index out of range ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20index-out-of-bounds) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L390) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L395) ## `invalid-argument-type` @@ -418,7 +418,7 @@ func("foo") # error: [invalid-argument-type] ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-argument-type) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L410) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L415) ## `invalid-assignment` @@ -445,7 +445,7 @@ a: int = '' ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-assignment) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L450) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L455) ## `invalid-attribute-access` @@ -478,7 +478,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-attribute-access) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1454) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1459) ## `invalid-base` @@ -501,7 +501,7 @@ class A(42): ... # error: [invalid-base] ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-base) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L472) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L477) ## `invalid-context-manager` @@ -527,7 +527,7 @@ with 1: ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-context-manager) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L523) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L528) ## `invalid-declaration` @@ -555,7 +555,7 @@ a: str ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-declaration) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L544) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L549) ## `invalid-exception-caught` @@ -596,7 +596,7 @@ except ZeroDivisionError: ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-exception-caught) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L567) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L572) ## `invalid-generic-class` @@ -627,7 +627,7 @@ class C[U](Generic[T]): ... ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-generic-class) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L603) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L608) ## `invalid-legacy-type-variable` @@ -660,7 +660,7 @@ def f(t: TypeVar("U")): ... ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-legacy-type-variable) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L629) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L634) ## `invalid-metaclass` @@ -692,7 +692,7 @@ class B(metaclass=f): ... ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-metaclass) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L678) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L683) ## `invalid-overload` @@ -740,7 +740,7 @@ def foo(x: int) -> int: ... ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-overload) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L705) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L710) ## `invalid-parameter-default` @@ -765,7 +765,7 @@ def f(a: int = ''): ... ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-parameter-default) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L748) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L753) ## `invalid-protocol` @@ -798,7 +798,7 @@ TypeError: Protocols can only inherit from other protocols, got ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-protocol) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L338) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L343) ## `invalid-raise` @@ -846,7 +846,7 @@ def g(): ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-raise) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L768) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L773) ## `invalid-return-type` @@ -870,7 +870,7 @@ def func() -> int: ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-return-type) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L431) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L436) ## `invalid-super-argument` @@ -914,7 +914,7 @@ super(B, A) # error: `A` does not satisfy `issubclass(A, B)` ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-super-argument) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L811) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L816) ## `invalid-syntax-in-forward-annotation` @@ -954,7 +954,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-alias-type) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L657) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L662) ## `invalid-type-checking-constant` @@ -983,7 +983,7 @@ TYPE_CHECKING = '' ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-checking-constant) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L850) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L855) ## `invalid-type-form` @@ -1012,7 +1012,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-form) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L874) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L879) ## `invalid-type-guard-call` @@ -1045,7 +1045,7 @@ f(10) # Error ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-guard-call) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L926) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L931) ## `invalid-type-guard-definition` @@ -1078,7 +1078,7 @@ class C: ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-guard-definition) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L898) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L903) ## `invalid-type-variable-constraints` @@ -1112,7 +1112,7 @@ T = TypeVar('T', bound=str) # valid bound TypeVar ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20invalid-type-variable-constraints) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L954) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L959) ## `missing-argument` @@ -1136,7 +1136,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x' ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20missing-argument) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L983) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L988) ## `no-matching-overload` @@ -1164,7 +1164,7 @@ func("string") # error: [no-matching-overload] ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20no-matching-overload) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1002) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1007) ## `non-subscriptable` @@ -1187,7 +1187,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20non-subscriptable) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1025) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1030) ## `not-iterable` @@ -1212,7 +1212,7 @@ for i in 34: # TypeError: 'int' object is not iterable ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20not-iterable) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1043) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1048) ## `parameter-already-assigned` @@ -1238,7 +1238,7 @@ f(1, x=2) # Error raised here ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20parameter-already-assigned) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1094) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1099) ## `raw-string-type-annotation` @@ -1297,7 +1297,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20static-assert-error) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1430) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1435) ## `subclass-of-final-class` @@ -1325,7 +1325,7 @@ class B(A): ... # Error raised here ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20subclass-of-final-class) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1185) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1190) ## `too-many-positional-arguments` @@ -1351,7 +1351,7 @@ f("foo") # Error raised here ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20too-many-positional-arguments) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1230) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1235) ## `type-assertion-failure` @@ -1378,7 +1378,7 @@ def _(x: int): ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20type-assertion-failure) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1208) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1213) ## `unavailable-implicit-super-arguments` @@ -1422,7 +1422,7 @@ class A: ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unavailable-implicit-super-arguments) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1251) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1256) ## `unknown-argument` @@ -1448,7 +1448,7 @@ f(x=1, y=2) # Error raised here ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unknown-argument) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1308) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1313) ## `unresolved-attribute` @@ -1475,7 +1475,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo' ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unresolved-attribute) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1329) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1334) ## `unresolved-import` @@ -1499,7 +1499,7 @@ import foo # ModuleNotFoundError: No module named 'foo' ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unresolved-import) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1351) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1356) ## `unresolved-reference` @@ -1523,7 +1523,7 @@ print(x) # NameError: name 'x' is not defined ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unresolved-reference) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1370) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1375) ## `unsupported-bool-conversion` @@ -1559,7 +1559,7 @@ b1 < b2 < b1 # exception raised here ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unsupported-bool-conversion) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1063) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1068) ## `unsupported-operator` @@ -1586,7 +1586,7 @@ A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A' ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unsupported-operator) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1389) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1394) ## `zero-stepsize-in-slice` @@ -1610,7 +1610,7 @@ l[1:10:0] # ValueError: slice step cannot be zero ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20zero-stepsize-in-slice) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1411) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1416) ## `invalid-ignore-comment` @@ -1666,7 +1666,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c' ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-unbound-attribute) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1115) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1120) ## `possibly-unbound-implicit-call` @@ -1697,7 +1697,7 @@ A()[0] # TypeError: 'A' object is not subscriptable ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-unbound-implicit-call) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L112) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L117) ## `possibly-unbound-import` @@ -1728,7 +1728,7 @@ from module import a # ImportError: cannot import name 'a' from 'module' ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-unbound-import) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1137) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1142) ## `redundant-cast` @@ -1754,7 +1754,7 @@ cast(int, f()) # Redundant ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20redundant-cast) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1482) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1487) ## `undefined-reveal` @@ -1777,7 +1777,7 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20undefined-reveal) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1290) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1295) ## `unknown-rule` @@ -1845,7 +1845,7 @@ class D(C): ... # error: [unsupported-base] ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20unsupported-base) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L490) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L495) ## `division-by-zero` @@ -1868,7 +1868,7 @@ Dividing by zero raises a `ZeroDivisionError` at runtime. ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20division-by-zero) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L241) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L246) ## `possibly-unresolved-reference` @@ -1895,7 +1895,7 @@ print(x) # NameError: name 'x' is not defined ### Links * [Related issues](https://github.com/astral-sh/ty/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20possibly-unresolved-reference) -* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1163) +* [View source](https://github.com/astral-sh/ruff/blob/main/crates%2Fty_python_semantic%2Fsrc%2Ftypes%2Fdiagnostic.rs#L1168) ## `unused-ignore-comment` From f7cdb884e214da5e3d8c0adf5aa3473287ed51d1 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 16 Jun 2025 13:48:52 +0100 Subject: [PATCH 19/19] fix checkout on Windows --- crates/ty_python_semantic/resources/mdtest/attributes.md | 2 +- ..._-_Suggestions_for_obvi\342\200\246_(bf7b28ef99f0ec16).snap" | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename "crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" => "crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Suggestions_for_obvi\342\200\246_(bf7b28ef99f0ec16).snap" (97%) diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 0273642e35ce5..62178922e9d56 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -2166,7 +2166,7 @@ reveal_type(Foo.BAR.value) # revealed: @Todo(Attribute access on enum classes) reveal_type(Foo.__members__) # revealed: @Todo(Attribute access on enum classes) ``` -## "Did you mean?" suggestions +## Suggestions for obvious typos diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Suggestions_for_obvi\342\200\246_(bf7b28ef99f0ec16).snap" similarity index 97% rename from "crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" rename to "crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Suggestions_for_obvi\342\200\246_(bf7b28ef99f0ec16).snap" index a4ebb1d57f9ba..350467007b6e3 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_\"Did_you_mean?\"_sugg\342\200\246_(8f02422c59d4eda9).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/attributes.md_-_Attributes_-_Suggestions_for_obvi\342\200\246_(bf7b28ef99f0ec16).snap" @@ -3,7 +3,7 @@ source: crates/ty_test/src/lib.rs expression: snapshot --- --- -mdtest name: attributes.md - Attributes - "Did you mean?" suggestions +mdtest name: attributes.md - Attributes - Suggestions for obvious typos mdtest path: crates/ty_python_semantic/resources/mdtest/attributes.md ---