add Salsa caching

This commit is contained in:
Alex Waygood 2025-06-17 22:17:33 +01:00
parent 79f3c8cf67
commit 592f30f626
4 changed files with 51 additions and 43 deletions

View File

@ -44,7 +44,10 @@ impl<'db> SemanticModel<'db> {
/// Returns completions for symbols available in a `object.<CURSOR>` context. /// Returns completions for symbols available in a `object.<CURSOR>` context.
pub fn attribute_completions(&self, node: &ast::ExprAttribute) -> Vec<Name> { pub fn attribute_completions(&self, node: &ast::ExprAttribute) -> Vec<Name> {
let ty = node.value.inferred_type(self); let ty = node.value.inferred_type(self);
crate::types::all_members(self.db, ty).into_iter().collect() crate::types::all_members(self.db, ty)
.iter()
.cloned()
.collect()
} }
/// Returns completions for symbols available in the scope containing the /// Returns completions for symbols available in the scope containing the

View File

@ -200,6 +200,16 @@ impl AllMembers {
/// List all members of a given type: anything that would be valid when accessed /// List all members of a given type: anything that would be valid when accessed
/// as an attribute on an object of the given type. /// as an attribute on an object of the given type.
pub fn all_members<'db>(db: &'db dyn Db, ty: Type<'db>) -> FxHashSet<Name> { pub fn all_members<'db>(db: &'db dyn Db, ty: Type<'db>) -> &'db FxHashSet<Name> {
AllMembers::of(db, ty).members /// This inner function is a Salsa query because [`AllMembers::extend_with_instance_members`]
/// calls [`semantic_index`] of another file, introducing a cross-file dependency.
///
/// The unused argument is necessary or Salsa won't let us add the `#[salsa::tracked]`
/// attribute.
#[salsa::tracked(returns(ref))]
fn all_members_impl<'db>(db: &'db dyn Db, ty: Type<'db>, _: ()) -> FxHashSet<Name> {
AllMembers::of(db, ty).members
}
all_members_impl(db, ty, ())
} }

View File

@ -669,10 +669,10 @@ impl<'db> Bindings<'db> {
if let [Some(ty)] = overload.parameter_types() { if let [Some(ty)] = overload.parameter_types() {
overload.set_return_type(TupleType::from_elements( overload.set_return_type(TupleType::from_elements(
db, db,
all_members::all_members(db, *ty) all_members(db, *ty)
.into_iter() .iter()
.sorted() .sorted()
.map(|member| Type::string_literal(db, &member)), .map(|member| Type::string_literal(db, member)),
)); ));
} }
} }

View File

@ -10,7 +10,6 @@ use crate::Db;
use crate::types::{Type, all_members}; use crate::types::{Type, all_members};
use indexmap::IndexSet; 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 /// Given a type and an unresolved member name, find the best suggestion for a member name
/// that is similar to the unresolved member name. /// that is similar to the unresolved member name.
@ -22,9 +21,11 @@ pub(crate) fn find_best_suggestion_for_unresolved_member<'db>(
obj: Type<'db>, obj: Type<'db>,
unresolved_member: &str, unresolved_member: &str,
hide_underscored_suggestions: HideUnderscoredSuggestions, hide_underscored_suggestions: HideUnderscoredSuggestions,
) -> Option<Name> { ) -> Option<&'db str> {
find_best_suggestion( find_best_suggestion(
all_members(db, obj), all_members(db, obj)
.iter()
.map(ruff_python_ast::name::Name::as_str),
unresolved_member, unresolved_member,
hide_underscored_suggestions, hide_underscored_suggestions,
) )
@ -45,14 +46,14 @@ impl HideUnderscoredSuggestions {
} }
} }
fn find_best_suggestion<O, I>( fn find_best_suggestion<'db, O, I>(
options: O, options: O,
unresolved_member: &str, unresolved_member: &str,
hide_underscored_suggestions: HideUnderscoredSuggestions, hide_underscored_suggestions: HideUnderscoredSuggestions,
) -> Option<Name> ) -> Option<&'db str>
where where
O: IntoIterator<IntoIter = I>, O: IntoIterator<IntoIter = I>,
I: ExactSizeIterator<Item = Name>, I: ExactSizeIterator<Item = &'db str>,
{ {
if unresolved_member.is_empty() { if unresolved_member.is_empty() {
return None; return None;
@ -76,9 +77,9 @@ where
// def bar(self): // def bar(self):
// print(self.attribute) # error: unresolved attribute `attribute`; did you mean `attribute`? // print(self.attribute) # error: unresolved attribute `attribute`; did you mean `attribute`?
// ``` // ```
let options = options.filter(|name| name != unresolved_member); let options = options.filter(|name| *name != unresolved_member);
let mut options: IndexSet<Name> = let mut options: IndexSet<&'db str> =
if hide_underscored_suggestions.is_no() || unresolved_member.starts_with('_') { if hide_underscored_suggestions.is_no() || unresolved_member.starts_with('_') {
options.collect() options.collect()
} else { } else {
@ -88,7 +89,10 @@ where
find_best_suggestion_impl(options, unresolved_member) find_best_suggestion_impl(options, unresolved_member)
} }
fn find_best_suggestion_impl(options: IndexSet<Name>, unresolved_member: &str) -> Option<Name> { fn find_best_suggestion_impl<'db>(
options: IndexSet<&'db str>,
unresolved_member: &str,
) -> Option<&'db str> {
let mut best_suggestion = None; let mut best_suggestion = None;
for member in options { for member in options {
@ -101,7 +105,7 @@ fn find_best_suggestion_impl(options: IndexSet<Name>, unresolved_member: &str) -
} }
} }
let current_distance = levenshtein_distance(unresolved_member, &member, max_distance); let current_distance = levenshtein_distance(unresolved_member, member, max_distance);
if current_distance > max_distance { if current_distance > max_distance {
continue; continue;
} }
@ -250,27 +254,23 @@ mod tests {
/// for the typo `bluch` is what we'd expect. /// for the typo `bluch` is what we'd expect.
/// ///
/// This test is ported from <https://github.com/python/cpython/blob/6eb6c5dbfb528bd07d77b60fd71fd05d81d45c41/Lib/test/test_traceback.py#L4037-L4078> /// This test is ported from <https://github.com/python/cpython/blob/6eb6c5dbfb528bd07d77b60fd71fd05d81d45c41/Lib/test/test_traceback.py#L4037-L4078>
#[test_case(&["noise", "more_noise", "a", "bc", "bluchin"], "bluchin"; "test for additional characters")] #[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", "blech"], "blech"; "test for substituted characters")]
#[test_case(&["noise", "more_noise", "a", "bc", "blch"], "blch"; "test for eliminated 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", "bluc"], "blach"; "substitutions are preferred over eliminations")]
#[test_case(&["blach", "bluchi"], "blach"; "substitutions are preferred over additions")] #[test_case(["blach", "bluchi"], "blach"; "substitutions are preferred over additions")]
#[test_case(&["blucha", "bluc"], "bluc"; "eliminations 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 substitutions")] #[test_case(["Luch", "fluch", "BLuch"], "BLuch"; "case changes are preferred over substitutions")]
fn test_good_suggestions(candidate_list: &[&str], expected_suggestion: &str) { fn test_good_suggestions<const T: usize>(candidate_list: [&str; T], expected_suggestion: &str) {
let candidates: Vec<Name> = candidate_list.iter().copied().map(Name::from).collect(); let suggestion =
let suggestion = find_best_suggestion(candidates, "bluch", HideUnderscoredSuggestions::No); find_best_suggestion(candidate_list, "bluch", HideUnderscoredSuggestions::No);
assert_eq!(suggestion.as_deref(), Some(expected_suggestion)); assert_eq!(suggestion, Some(expected_suggestion));
} }
/// Test ported from <https://github.com/python/cpython/blob/6eb6c5dbfb528bd07d77b60fd71fd05d81d45c41/Lib/test/test_traceback.py#L4080-L4099> /// Test ported from <https://github.com/python/cpython/blob/6eb6c5dbfb528bd07d77b60fd71fd05d81d45c41/Lib/test/test_traceback.py#L4080-L4099>
#[test] #[test]
fn underscored_names_not_suggested_if_hide_policy_set_to_yes() { fn underscored_names_not_suggested_if_hide_policy_set_to_yes() {
let suggestion = find_best_suggestion( let suggestion = find_best_suggestion(["bluch"], "bluch", HideUnderscoredSuggestions::Yes);
[Name::from("_bluch")],
"bluch",
HideUnderscoredSuggestions::Yes,
);
if let Some(suggestion) = suggestion { if let Some(suggestion) = suggestion {
panic!( panic!(
"Expected no suggestions for `bluch` due to `HideUnderscoredSuggestions::Yes` but `{suggestion}` was suggested" "Expected no suggestions for `bluch` due to `HideUnderscoredSuggestions::Yes` but `{suggestion}` was suggested"
@ -284,21 +284,16 @@ mod tests {
fn underscored_names_are_suggested_if_hide_policy_set_to_yes_when_typo_is_underscored( fn underscored_names_are_suggested_if_hide_policy_set_to_yes_when_typo_is_underscored(
typo: &str, typo: &str,
) { ) {
let suggestion = find_best_suggestion( let suggestion = find_best_suggestion(["_bluch"], typo, HideUnderscoredSuggestions::Yes);
[Name::from("_bluch")], assert_eq!(suggestion, Some("_bluch"));
typo,
HideUnderscoredSuggestions::Yes,
);
assert_eq!(suggestion.as_deref(), Some("_bluch"));
} }
/// Test ported from <https://github.com/python/cpython/blob/6eb6c5dbfb528bd07d77b60fd71fd05d81d45c41/Lib/test/test_traceback.py#L4080-L4099> /// Test ported from <https://github.com/python/cpython/blob/6eb6c5dbfb528bd07d77b60fd71fd05d81d45c41/Lib/test/test_traceback.py#L4080-L4099>
#[test_case("_luch")] #[test_case("_luch")]
#[test_case("_bluch")] #[test_case("_bluch")]
fn non_underscored_names_always_suggested_even_if_typo_underscored(typo: &str) { fn non_underscored_names_always_suggested_even_if_typo_underscored(typo: &str) {
let suggestion = let suggestion = find_best_suggestion(["bluch"], typo, HideUnderscoredSuggestions::Yes);
find_best_suggestion([Name::from("bluch")], typo, HideUnderscoredSuggestions::Yes); assert_eq!(suggestion, Some("bluch"));
assert_eq!(suggestion.as_deref(), Some("bluch"));
} }
/// This asserts that we do not offer silly suggestions for very small names. /// This asserts that we do not offer silly suggestions for very small names.
@ -308,7 +303,7 @@ mod tests {
#[test_case("m")] #[test_case("m")]
#[test_case("py")] #[test_case("py")]
fn test_bad_suggestions_do_not_trigger_for_small_names(typo: &str) { fn test_bad_suggestions_do_not_trigger_for_small_names(typo: &str) {
let candidates = ["vvv", "mom", "w", "id", "pytho"].map(Name::from); let candidates = ["vvv", "mom", "w", "id", "pytho"];
let suggestion = find_best_suggestion(candidates, typo, HideUnderscoredSuggestions::No); let suggestion = find_best_suggestion(candidates, typo, HideUnderscoredSuggestions::No);
if let Some(suggestion) = suggestion { if let Some(suggestion) = suggestion {
panic!("Expected no suggestions for `{typo}` but `{suggestion}` was suggested"); panic!("Expected no suggestions for `{typo}` but `{suggestion}` was suggested");
@ -320,7 +315,7 @@ mod tests {
fn test_no_suggestion_for_very_different_attribute() { fn test_no_suggestion_for_very_different_attribute() {
assert_eq!( assert_eq!(
find_best_suggestion( find_best_suggestion(
[Name::from("blech")], ["blech"],
"somethingverywrong", "somethingverywrong",
HideUnderscoredSuggestions::No HideUnderscoredSuggestions::No
), ),