Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions crates/ty_ide/src/references.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,26 @@ fn is_ascii_identifier_continue(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}

/// Returns whether `node` is an assignment whose (sole) target is the name
/// `__slots__`, e.g. `__slots__ = (...)` or `__slots__: tuple = (...)`.
fn is_slots_assignment(node: AnyNodeRef<'_>) -> bool {
match node {
AnyNodeRef::StmtAssign(assign) => {
matches!(
assign.targets.as_slice(),
[ast::Expr::Name(name)] if name.id.as_str() == "__slots__"
)
}
AnyNodeRef::StmtAnnAssign(assign) => {
matches!(
assign.target.as_ref(),
ast::Expr::Name(name) if name.id.as_str() == "__slots__"
)
}
_ => false,
}
}

/// Find all references to a local symbol within the current file.
/// The behavior depends on the provided mode.
fn references_for_file(
Expand Down Expand Up @@ -452,6 +472,11 @@ impl<'a> SourceOrderVisitor<'a> for LocalReferencesFinder<'a> {
self.check_declaration_identifier(&param_var.name);
}
AnyNodeRef::ExprStringLiteral(string_expr) => {
// A string literal listed in a class's `__slots__` names an
// instance attribute, so renaming that attribute should rename
// the matching slot string too.
self.check_slots_string_literal(string_expr);

// Highlight the sub-AST of a string annotation
if let Some((sub_ast, sub_model)) = self.model.enter_string_annotation(string_expr)
{
Expand Down Expand Up @@ -589,6 +614,129 @@ impl<'a> LocalReferencesFinder<'a> {
self.references.push(target);
}

/// Checks a string literal that may be an entry in a class's `__slots__`.
///
/// `__slots__` entries are plain strings, but they name instance
/// attributes of the enclosing class. When the rename target is one of
/// those attributes, the matching slot string should be renamed as well.
/// We only do this when the attribute belongs to the same class that
/// declares the `__slots__`, so unrelated classes that happen to use the
/// same slot name are left untouched.
fn check_slots_string_literal(&mut self, string_expr: &'a ast::ExprStringLiteral) {
// Quick text-based check first. We only handle single-part string
// literals; implicitly concatenated strings ("a" "b") can't name a
// single attribute, so they never match an identifier here.
if string_expr.value.is_implicit_concatenated() {
return;
}
let [part] = string_expr.value.as_slice() else {
return;
};
if part.value.as_ref() != self.target_text {
return;
}

// The literal must sit directly inside a tuple/list/set container, or
// be a key in a dict, that is the value of a `__slots__` assignment in
// a class body.
let Some(class) = self.enclosing_slots_class() else {
return;
};

// Only rename the slot if the target attribute belongs to this class.
if !self.target_belongs_to_class(class) {
return;
}

// Rename the inner content of the string, leaving the quotes intact.
let content_range = ast::StringLikePart::String(part).content_range();
let target = ReferenceTarget::new(self.model.file(), content_range, ReferenceKind::Other);
self.references.push(target);
}

/// Returns the class definition whose `__slots__` assignment contains the
/// string literal currently being visited, if the ancestor chain matches
/// one of the supported `__slots__` shapes.
///
/// Supported shapes (v1):
/// - `__slots__ = ("a", "b")` / `["a", "b"]` / `{"a", "b"}`
/// - `__slots__ = {"a": ..., "b": ...}` (dict keys only)
fn enclosing_slots_class(&self) -> Option<&'a ast::StmtClassDef> {
// `self.ancestors` ends with the string literal itself. Walk outward.
let mut ancestors = self.ancestors.iter().rev();

// The string is either a direct element of a tuple/list/set, or a key
// of a dict. Skip the immediate container node.
match ancestors.next()? {
AnyNodeRef::ExprStringLiteral(_) => {}
_ => return None,
}
match ancestors.next()? {
AnyNodeRef::ExprTuple(_) | AnyNodeRef::ExprList(_) | AnyNodeRef::ExprSet(_) => {}
// For a dict, both keys and values have the `ExprDict` as their
// direct parent, so `is_dict_key` checks that this string is one of
// the keys before we treat it as a slot name.
AnyNodeRef::ExprDict(dict) => {
if !self.is_dict_key(dict) {
return None;
}
}
_ => return None,
}

// The container must be the value of an assignment to `__slots__`.
let assignment = ancestors.next()?;
if !is_slots_assignment(*assignment) {
return None;
}

// That assignment must live directly in a class body.
match ancestors.next()? {
AnyNodeRef::StmtClassDef(class) => Some(class),
_ => None,
}
}

/// Returns whether the string literal currently being visited is a *key*
/// of `dict`, as opposed to one of its values.
fn is_dict_key(&self, dict: &'a ast::ExprDict) -> bool {
let Some(AnyNodeRef::ExprStringLiteral(string_expr)) = self.ancestors.last() else {
return false;
};
dict.items.iter().any(|item| {
item.key
.as_ref()
.is_some_and(|key| key.range() == string_expr.range())
})
}

/// Returns whether any of the rename target's definitions belongs to
/// `class` (i.e. the class scope is an ancestor of the definition's scope).
fn target_belongs_to_class(&self, class: &'a ast::StmtClassDef) -> bool {
let db = self.model.db();
let file = self.model.file();
let class_range = class.range();
let module = ruff_db::parsed::parsed_module(db, file).load(db);
let index = ty_python_core::semantic_index(db, file);

self.target_definitions.iter().any(|resolved| {
let Some(definition) = resolved.definition() else {
return false;
};
if definition.file(db) != file {
return false;
}
index
.ancestor_scopes(definition.file_scope(db))
.any(|(_, scope)| match scope.node() {
ty_python_core::scope::NodeWithScopeKind::Class(node) => {
node.node(&module).range() == class_range
}
_ => false,
})
})
}

fn is_declaration(&self, covering_node: &CoveringNode<'_>) -> bool {
let db = self.model.db();

Expand Down
176 changes: 176 additions & 0 deletions crates/ty_ide/src/rename.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2705,4 +2705,180 @@ DC(f=1)
|
"#);
}

#[test]
fn rename_attribute_updates_slots_tuple() {
let test = cursor_test(
r#"
class C:
__slots__ = ("value", "other")

def __init__(self):
self.va<CURSOR>lue = 1
"#,
);

assert_snapshot!(test.rename("amount"), @r#"
info[rename]: Rename symbol (found 2 locations)
--> main.py:3:19
|
3 | __slots__ = ("value", "other")
| ^^^^^
4 |
5 | def __init__(self):
6 | self.value = 1
| -----
|
"#);
}

#[test]
fn rename_attribute_updates_slots_list() {
let test = cursor_test(
r#"
class C:
__slots__ = ["value", "other"]

def __init__(self):
self.va<CURSOR>lue = 1
"#,
);

assert_snapshot!(test.rename("amount"), @r#"
info[rename]: Rename symbol (found 2 locations)
--> main.py:3:19
|
3 | __slots__ = ["value", "other"]
| ^^^^^
4 |
5 | def __init__(self):
6 | self.value = 1
| -----
|
"#);
}

#[test]
fn rename_attribute_updates_slots_set() {
let test = cursor_test(
r#"
class C:
__slots__ = {"value", "other"}

def __init__(self):
self.va<CURSOR>lue = 1
"#,
);

assert_snapshot!(test.rename("amount"), @r#"
info[rename]: Rename symbol (found 2 locations)
--> main.py:3:19
|
3 | __slots__ = {"value", "other"}
| ^^^^^
4 |
5 | def __init__(self):
6 | self.value = 1
| -----
|
"#);
}

#[test]
fn rename_attribute_updates_slots_dict_key() {
let test = cursor_test(
r#"
class C:
__slots__ = {"value": "doc", "other": "doc"}

def __init__(self):
self.va<CURSOR>lue = 1
"#,
);

assert_snapshot!(test.rename("amount"), @r#"
info[rename]: Rename symbol (found 2 locations)
--> main.py:3:19
|
3 | __slots__ = {"value": "doc", "other": "doc"}
| ^^^^^
4 |
5 | def __init__(self):
6 | self.value = 1
| -----
|
"#);
}

#[test]
fn rename_cannot_start_from_slot_string() {
// Renaming is driven from the attribute; the `__slots__` string itself
// is not a renameable symbol, so starting a rename on it is rejected.
let test = cursor_test(
r#"
class C:
__slots__ = ("va<CURSOR>lue",)

def __init__(self):
self.value = 1
"#,
);

assert_snapshot!(test.rename("amount"), @"Cannot rename");
}

#[test]
fn rename_attribute_does_not_touch_other_class_slots() {
let test = cursor_test(
r#"
class C:
__slots__ = ("value",)

def __init__(self):
self.va<CURSOR>lue = 1

class D:
__slots__ = ("value",)
"#,
);

assert_snapshot!(test.rename("amount"), @r#"
info[rename]: Rename symbol (found 2 locations)
--> main.py:3:19
|
3 | __slots__ = ("value",)
| ^^^^^
4 |
5 | def __init__(self):
6 | self.value = 1
| -----
|
"#);
}

#[test]
fn rename_attribute_does_not_touch_slots_dict_value() {
let test = cursor_test(
r#"
class C:
__slots__ = {"value": "value"}

def __init__(self):
self.va<CURSOR>lue = 1
"#,
);

assert_snapshot!(test.rename("amount"), @r#"
info[rename]: Rename symbol (found 2 locations)
--> main.py:3:19
|
3 | __slots__ = {"value": "value"}
| ^^^^^
4 |
5 | def __init__(self):
6 | self.value = 1
| -----
|
"#);
}
}
Loading