[ty] Implemented support for "rename" language server feature (#19551)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks-instrumented (push) Blocked by required conditions
CI / benchmarks-walltime (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

This PR adds support for the "rename" language server feature. It builds
upon existing functionality used for "go to references".

The "rename" feature involves two language server requests. The first is
a "prepare rename" request that determines whether renaming should be
possible for the identifier at the current offset. The second is a
"rename" request that returns a list of file ranges where the rename
should be applied.

Care must be taken when attempting to rename symbols that span files,
especially if the symbols are defined in files that are not part of the
project. We don't want to modify code in the user's Python environment
or in the vendored stub files.

I found a few bugs in the "go to references" feature when implementing
"rename", and those bug fixes are included in this PR.

---------

Co-authored-by: UnboundVariable <unbound@gmail.com>
This commit is contained in:
UnboundVariable 2025-08-07 03:28:18 -07:00 committed by GitHub
parent b96aa4605b
commit b005cdb7ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1065 additions and 73 deletions

View file

@ -10,6 +10,7 @@ use ruff_db::parsed::ParsedModuleRef;
use ruff_python_ast::{self as ast, AnyNodeRef};
use ruff_python_parser::TokenKind;
use ruff_text_size::{Ranged, TextRange, TextSize};
use ty_python_semantic::ImportAliasResolution;
use ty_python_semantic::types::Type;
use ty_python_semantic::types::definitions_for_keyword_argument;
use ty_python_semantic::{
@ -172,12 +173,16 @@ impl GotoTarget<'_> {
/// Gets the navigation ranges for this goto target.
/// If a stub mapper is provided, definitions from stub files will be mapped to
/// their corresponding source file implementations.
/// their corresponding source file implementations. The `alias_resolution`
/// parameter controls whether import aliases (i.e. "x" in "from a import b as x") are
/// resolved or returned as is. We want to resolve them in some cases (like
/// "goto declaration") but not in others (like find references or rename).
pub(crate) fn get_definition_targets(
&self,
file: ruff_db::files::File,
db: &dyn crate::Db,
stub_mapper: Option<&StubMapper>,
alias_resolution: ImportAliasResolution,
) -> Option<crate::NavigationTargets> {
use crate::NavigationTarget;
use ruff_python_ast as ast;
@ -229,10 +234,14 @@ impl GotoTarget<'_> {
GotoTarget::ImportSymbolAlias {
alias, import_from, ..
} => {
// Handle both original names and alias names in `from x import y as z` statements
let symbol_name = alias.name.as_str();
let definitions =
definitions_for_imported_symbol(db, file, import_from, symbol_name);
let definitions = definitions_for_imported_symbol(
db,
file,
import_from,
symbol_name,
alias_resolution,
);
definitions_to_navigation_targets(db, stub_mapper, definitions)
}
@ -254,12 +263,18 @@ impl GotoTarget<'_> {
// Handle import aliases (offset within 'z' in "import x.y as z")
GotoTarget::ImportModuleAlias { alias } => {
// For import aliases, navigate to the module being aliased
// This only applies to regular import statements like "import x.y as z"
if alias_resolution == ImportAliasResolution::ResolveAliases {
let full_module_name = alias.name.as_str();
// Try to resolve the module
resolve_module_to_navigation_target(db, full_module_name)
} else {
let alias_range = alias.asname.as_ref().unwrap().range;
Some(crate::NavigationTargets::single(NavigationTarget {
file,
focus_range: alias_range,
full_range: alias.range(),
}))
}
}
// Handle keyword arguments in call expressions

View file

@ -3,6 +3,7 @@ use crate::{Db, NavigationTargets, RangedValue};
use ruff_db::files::{File, FileRange};
use ruff_db::parsed::parsed_module;
use ruff_text_size::{Ranged, TextSize};
use ty_python_semantic::ImportAliasResolution;
/// Navigate to the declaration of a symbol.
///
@ -17,7 +18,12 @@ pub fn goto_declaration(
let module = parsed_module(db, file).load(db);
let goto_target = find_goto_target(&module, offset)?;
let declaration_targets = goto_target.get_definition_targets(file, db, None)?;
let declaration_targets = goto_target.get_definition_targets(
file,
db,
None,
ImportAliasResolution::ResolveAliases,
)?;
Some(RangedValue {
range: FileRange::new(file, goto_target.range()),

View file

@ -4,6 +4,7 @@ use crate::{Db, NavigationTargets, RangedValue};
use ruff_db::files::{File, FileRange};
use ruff_db::parsed::parsed_module;
use ruff_text_size::{Ranged, TextSize};
use ty_python_semantic::ImportAliasResolution;
/// Navigate to the definition of a symbol.
///
@ -22,7 +23,12 @@ pub fn goto_definition(
// Create a StubMapper to map from stub files to source files
let stub_mapper = StubMapper::new(db);
let definition_targets = goto_target.get_definition_targets(file, db, Some(&stub_mapper))?;
let definition_targets = goto_target.get_definition_targets(
file,
db,
Some(&stub_mapper),
ImportAliasResolution::ResolveAliases,
)?;
Some(RangedValue {
range: FileRange::new(file, goto_target.range()),

View file

@ -688,34 +688,30 @@ cls = MyClass
.source(
"utils.py",
"
def helper_fun<CURSOR>ction(x):
def fun<CURSOR>c(x):
return x * 2
",
)
.source(
"module.py",
"
from utils import helper_function
from utils import func
def process_data(data):
return helper_function(data)
def double_process(data):
result = helper_function(data)
return helper_function(result)
return func(data)
",
)
.source(
"app.py",
"
from utils import helper_function
from utils import func
class DataProcessor:
def __init__(self):
self.multiplier = helper_function
self.multiplier = func
def process(self, value):
return helper_function(value)
return func(value)
",
)
.build();
@ -724,37 +720,35 @@ class DataProcessor:
info[references]: Reference 1
--> utils.py:2:5
|
2 | def helper_function(x):
| ^^^^^^^^^^^^^^^
2 | def func(x):
| ^^^^
3 | return x * 2
|
info[references]: Reference 2
--> module.py:5:12
--> module.py:2:19
|
2 | from utils import func
| ^^^^
3 |
4 | def process_data(data):
5 | return helper_function(data)
| ^^^^^^^^^^^^^^^
6 |
7 | def double_process(data):
|
info[references]: Reference 3
--> module.py:8:14
--> module.py:5:12
|
7 | def double_process(data):
8 | result = helper_function(data)
| ^^^^^^^^^^^^^^^
9 | return helper_function(result)
4 | def process_data(data):
5 | return func(data)
| ^^^^
|
info[references]: Reference 4
--> module.py:9:12
--> app.py:2:19
|
7 | def double_process(data):
8 | result = helper_function(data)
9 | return helper_function(result)
| ^^^^^^^^^^^^^^^
2 | from utils import func
| ^^^^
3 |
4 | class DataProcessor:
|
info[references]: Reference 5
@ -762,8 +756,8 @@ class DataProcessor:
|
4 | class DataProcessor:
5 | def __init__(self):
6 | self.multiplier = helper_function
| ^^^^^^^^^^^^^^^
6 | self.multiplier = func
| ^^^^
7 |
8 | def process(self, value):
|
@ -772,8 +766,8 @@ class DataProcessor:
--> app.py:9:16
|
8 | def process(self, value):
9 | return helper_function(value)
| ^^^^^^^^^^^^^^^
9 | return func(value)
| ^^^^
|
");
}
@ -855,4 +849,49 @@ def process_model():
|
");
}
#[test]
fn test_import_alias_references_should_not_resolve_to_original() {
let test = CursorTest::builder()
.source(
"original.py",
"
def func():
pass
func()
",
)
.source(
"importer.py",
"
from original import func as func_alias
func<CURSOR>_alias()
",
)
.build();
// When finding references to the alias, we should NOT find references
// to the original function in the original module
assert_snapshot!(test.references(), @r"
info[references]: Reference 1
--> importer.py:2:30
|
2 | from original import func as func_alias
| ^^^^^^^^^^
3 |
4 | func_alias()
|
info[references]: Reference 2
--> importer.py:4:1
|
2 | from original import func as func_alias
3 |
4 | func_alias()
| ^^^^^^^^^^
|
");
}
}

View file

@ -12,6 +12,7 @@ mod hover;
mod inlay_hints;
mod markup;
mod references;
mod rename;
mod selection_range;
mod semantic_tokens;
mod signature_help;
@ -29,6 +30,7 @@ pub use hover::hover;
pub use inlay_hints::inlay_hints;
pub use markup::MarkupKind;
pub use references::ReferencesMode;
pub use rename::{can_rename, rename};
pub use selection_range::selection_range;
pub use semantic_tokens::{
SemanticToken, SemanticTokenModifier, SemanticTokenType, SemanticTokens, semantic_tokens,

View file

@ -19,6 +19,7 @@ use ruff_python_ast::{
visitor::source_order::{SourceOrderVisitor, TraversalSignal},
};
use ruff_text_size::{Ranged, TextRange};
use ty_python_semantic::ImportAliasResolution;
/// Mode for references search behavior
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -27,8 +28,10 @@ pub enum ReferencesMode {
References,
/// Find all references but skip the declaration
ReferencesSkipDeclaration,
/// Find references for rename operations (behavior differs for imported symbols)
/// Find references for rename operations, limited to current file only
Rename,
/// Find references for multi-file rename operations (searches across all files)
RenameMultiFile,
/// Find references for document highlights (limits search to current file)
DocumentHighlights,
}
@ -42,7 +45,14 @@ pub(crate) fn references(
mode: ReferencesMode,
) -> Option<Vec<ReferenceTarget>> {
// Get the definitions for the symbol at the cursor position
let target_definitions_nav = goto_target.get_definition_targets(file, db, None)?;
// When finding references, do not resolve any local aliases.
let target_definitions_nav = goto_target.get_definition_targets(
file,
db,
None,
ImportAliasResolution::PreserveAliases,
)?;
let target_definitions: Vec<NavigationTarget> = target_definitions_nav.into_iter().collect();
// Extract the target text from the goto target for fast comparison
@ -60,7 +70,12 @@ pub(crate) fn references(
);
// Check if we should search across files based on the mode
let search_across_files = !matches!(mode, ReferencesMode::DocumentHighlights);
let search_across_files = matches!(
mode,
ReferencesMode::References
| ReferencesMode::ReferencesSkipDeclaration
| ReferencesMode::RenameMultiFile
);
// Check if the symbol is potentially visible outside of this module
if search_across_files && is_symbol_externally_visible(goto_target) {
@ -211,6 +226,17 @@ impl<'a> SourceOrderVisitor<'a> for LocalReferencesFinder<'a> {
self.check_identifier_reference(rest_name);
}
}
AnyNodeRef::Alias(alias) if self.should_include_declaration() => {
// Handle import alias declarations
if let Some(asname) = &alias.asname {
self.check_identifier_reference(asname);
}
// Only check the original name if it matches our target text
// This is for cases where we're renaming the imported symbol name itself
if alias.name.id == self.target_text {
self.check_identifier_reference(&alias.name);
}
}
_ => {}
}
@ -231,6 +257,7 @@ impl LocalReferencesFinder<'_> {
ReferencesMode::References
| ReferencesMode::DocumentHighlights
| ReferencesMode::Rename
| ReferencesMode::RenameMultiFile
)
}
@ -259,21 +286,21 @@ impl LocalReferencesFinder<'_> {
let offset = covering_node.node().start();
if let Some(goto_target) = GotoTarget::from_covering_node(covering_node, offset) {
// Use the range of the covering node (the identifier) rather than the goto target
// This ensures we highlight just the identifier, not the entire expression
let range = covering_node.node().range();
// Get the definitions for this goto target
if let Some(current_definitions_nav) =
goto_target.get_definition_targets(self.file, self.db, None)
{
if let Some(current_definitions_nav) = goto_target.get_definition_targets(
self.file,
self.db,
None,
ImportAliasResolution::PreserveAliases,
) {
let current_definitions: Vec<NavigationTarget> =
current_definitions_nav.into_iter().collect();
// Check if any of the current definitions match our target definitions
if self.navigation_targets_match(&current_definitions) {
// Determine if this is a read or write reference
let kind = self.determine_reference_kind(covering_node);
let target = ReferenceTarget::new(self.file, range, kind);
let target =
ReferenceTarget::new(self.file, covering_node.node().range(), kind);
self.references.push(target);
}
}

641
crates/ty_ide/src/rename.rs Normal file
View file

@ -0,0 +1,641 @@
use crate::goto::find_goto_target;
use crate::references::{ReferencesMode, references};
use crate::{Db, ReferenceTarget};
use ruff_db::files::File;
use ruff_text_size::{Ranged, TextSize};
use ty_python_semantic::ImportAliasResolution;
/// Returns the range of the symbol if it can be renamed, None if not.
pub fn can_rename(db: &dyn Db, file: File, offset: TextSize) -> Option<ruff_text_size::TextRange> {
let parsed = ruff_db::parsed::parsed_module(db, file);
let module = parsed.load(db);
// Get the definitions for the symbol at the offset
let goto_target = find_goto_target(&module, offset)?;
// Don't allow renaming of import module components
if matches!(
goto_target,
crate::goto::GotoTarget::ImportModuleComponent { .. }
) {
return None;
}
let current_file_in_project = is_file_in_project(db, file);
if let Some(definition_targets) =
goto_target.get_definition_targets(file, db, None, ImportAliasResolution::PreserveAliases)
{
for target in &definition_targets {
let target_file = target.file();
// If definition is outside the project, refuse rename
if !is_file_in_project(db, target_file) {
return None;
}
// If current file is not in project and any definition is outside current file, refuse rename
if !current_file_in_project && target_file != file {
return None;
}
}
} else {
// No definition targets found. This happens for keywords, so refuse rename
return None;
}
Some(goto_target.range())
}
/// Perform a rename operation on the symbol at the given position.
/// Returns all locations that need to be updated with the new name.
pub fn rename(
db: &dyn Db,
file: File,
offset: TextSize,
new_name: &str,
) -> Option<Vec<ReferenceTarget>> {
let parsed = ruff_db::parsed::parsed_module(db, file);
let module = parsed.load(db);
// Get the definitions for the symbol at the offset
let goto_target = find_goto_target(&module, offset)?;
// Clients shouldn't call us with an empty new name, but just in case...
if new_name.is_empty() {
return None;
}
// Determine if we should do a multi-file rename or single-file rename
// based on whether the current file is part of the project
let current_file_in_project = is_file_in_project(db, file);
// Choose the appropriate rename mode:
// - If current file is in project, do multi-file rename
// - If current file is not in project, limit to single-file rename
let rename_mode = if current_file_in_project {
ReferencesMode::RenameMultiFile
} else {
ReferencesMode::Rename
};
// Find all references that need to be renamed
references(db, file, &goto_target, rename_mode)
}
/// Helper function to check if a file is included in the project.
fn is_file_in_project(db: &dyn Db, file: File) -> bool {
db.project().files(db).contains(&file)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::{CursorTest, IntoDiagnostic, cursor_test};
use insta::assert_snapshot;
use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span};
use ruff_db::files::FileRange;
use ruff_text_size::Ranged;
impl CursorTest {
fn prepare_rename(&self) -> String {
let Some(range) = can_rename(&self.db, self.cursor.file, self.cursor.offset) else {
return "Cannot rename".to_string();
};
format!("Can rename symbol at range {range:?}")
}
fn rename(&self, new_name: &str) -> String {
let Some(rename_results) =
rename(&self.db, self.cursor.file, self.cursor.offset, new_name)
else {
return "Cannot rename".to_string();
};
if rename_results.is_empty() {
return "No locations to rename".to_string();
}
// Create a single diagnostic with multiple annotations
let rename_diagnostic = RenameResultSet {
locations: rename_results
.into_iter()
.map(|ref_item| FileRange::new(ref_item.file(), ref_item.range()))
.collect(),
};
self.render_diagnostics([rename_diagnostic])
}
}
struct RenameResultSet {
locations: Vec<FileRange>,
}
impl IntoDiagnostic for RenameResultSet {
fn into_diagnostic(self) -> Diagnostic {
let mut main = Diagnostic::new(
DiagnosticId::Lint(LintName::of("rename")),
Severity::Info,
format!("Rename symbol (found {} locations)", self.locations.len()),
);
// Add the first location as primary annotation (the symbol being renamed)
if let Some(first_location) = self.locations.first() {
main.annotate(Annotation::primary(
Span::from(first_location.file()).with_range(first_location.range()),
));
// Add remaining locations as secondary annotations
for location in &self.locations[1..] {
main.annotate(Annotation::secondary(
Span::from(location.file()).with_range(location.range()),
));
}
}
main
}
}
#[test]
fn test_prepare_rename_parameter() {
let test = cursor_test(
"
def func(<CURSOR>value: int) -> int:
value *= 2
return value
value = 0
",
);
assert_snapshot!(test.prepare_rename(), @"Can rename symbol at range 10..15");
}
#[test]
fn test_rename_parameter() {
let test = cursor_test(
"
def func(<CURSOR>value: int) -> int:
value *= 2
return value
func(value=42)
",
);
assert_snapshot!(test.rename("number"), @r"
info[rename]: Rename symbol (found 4 locations)
--> main.py:2:10
|
2 | def func(value: int) -> int:
| ^^^^^
3 | value *= 2
| -----
4 | return value
| -----
5 |
6 | func(value=42)
| -----
|
");
}
#[test]
fn test_rename_function() {
let test = cursor_test(
"
def fu<CURSOR>nc():
pass
result1 = func()
x = func
",
);
assert_snapshot!(test.rename("calculate"), @r"
info[rename]: Rename symbol (found 3 locations)
--> main.py:2:5
|
2 | def func():
| ^^^^
3 | pass
4 |
5 | result1 = func()
| ----
6 | x = func
| ----
|
");
}
#[test]
fn test_rename_class() {
let test = cursor_test(
"
class My<CURSOR>Class:
def __init__(self):
pass
obj1 = MyClass()
cls = MyClass
",
);
assert_snapshot!(test.rename("MyNewClass"), @r"
info[rename]: Rename symbol (found 3 locations)
--> main.py:2:7
|
2 | class MyClass:
| ^^^^^^^
3 | def __init__(self):
4 | pass
5 |
6 | obj1 = MyClass()
| -------
7 | cls = MyClass
| -------
|
");
}
#[test]
fn test_rename_invalid_name() {
let test = cursor_test(
"
def fu<CURSOR>nc():
pass
",
);
assert_snapshot!(test.rename(""), @"Cannot rename");
assert_snapshot!(test.rename("valid_name"), @r"
info[rename]: Rename symbol (found 1 locations)
--> main.py:2:5
|
2 | def func():
| ^^^^
3 | pass
|
");
}
#[test]
fn test_multi_file_function_rename() {
let test = CursorTest::builder()
.source(
"utils.py",
"
def fu<CURSOR>nc(x):
return x * 2
",
)
.source(
"module.py",
"
from utils import func
def test(data):
return func(data)
",
)
.source(
"app.py",
"
from utils import helper_function
class DataProcessor:
def __init__(self):
self.multiplier = helper_function
def process(self, value):
return helper_function(value)
",
)
.build();
assert_snapshot!(test.rename("utility_function"), @r"
info[rename]: Rename symbol (found 3 locations)
--> utils.py:2:5
|
2 | def func(x):
| ^^^^
3 | return x * 2
|
::: module.py:2:19
|
2 | from utils import func
| ----
3 |
4 | def test(data):
5 | return func(data)
| ----
|
");
}
#[test]
fn test_cannot_rename_import_module_component() {
// Test that we cannot rename parts of module names in import statements
let test = cursor_test(
"
import <CURSOR>os.path
x = os.path.join('a', 'b')
",
);
assert_snapshot!(test.prepare_rename(), @"Cannot rename");
}
#[test]
fn test_cannot_rename_from_import_module_component() {
// Test that we cannot rename parts of module names in from import statements
let test = cursor_test(
"
from os.<CURSOR>path import join
result = join('a', 'b')
",
);
assert_snapshot!(test.prepare_rename(), @"Cannot rename");
}
#[test]
fn test_cannot_rename_external_file() {
// This test verifies that we cannot rename a symbol when it's defined in a file
// that's outside the project (like a standard library function)
let test = cursor_test(
"
import os
x = <CURSOR>os.path.join('a', 'b')
",
);
assert_snapshot!(test.prepare_rename(), @"Cannot rename");
}
#[test]
fn test_rename_alias_at_import_statement() {
let test = CursorTest::builder()
.source(
"utils.py",
"
def test(): pass
",
)
.source(
"main.py",
"
from utils import test as test_<CURSOR>alias
result = test_alias()
",
)
.build();
assert_snapshot!(test.rename("new_alias"), @r"
info[rename]: Rename symbol (found 2 locations)
--> main.py:2:27
|
2 | from utils import test as test_alias
| ^^^^^^^^^^
3 | result = test_alias()
| ----------
|
");
}
#[test]
fn test_rename_alias_at_usage_site() {
// Test renaming an alias when the cursor is on the alias in the usage statement
let test = CursorTest::builder()
.source(
"utils.py",
"
def test(): pass
",
)
.source(
"main.py",
"
from utils import test as test_alias
result = test_<CURSOR>alias()
",
)
.build();
assert_snapshot!(test.rename("new_alias"), @r"
info[rename]: Rename symbol (found 2 locations)
--> main.py:2:27
|
2 | from utils import test as test_alias
| ^^^^^^^^^^
3 | result = test_alias()
| ----------
|
");
}
#[test]
fn test_rename_across_import_chain_with_mixed_aliases() {
// Test renaming a symbol that's imported across multiple files with mixed alias patterns
// File 1 (source.py): defines the original function
// File 2 (middle.py): imports without alias from source.py
// File 3 (consumer.py): imports with alias from middle.py
let test = CursorTest::builder()
.source(
"source.py",
"
def original_func<CURSOR>tion():
return 'Hello from source'
",
)
.source(
"middle.py",
"
from source import original_function
def wrapper():
return original_function()
result = original_function()
",
)
.source(
"consumer.py",
"
from middle import original_function as func_alias
def process():
return func_alias()
value1 = func_alias()
",
)
.build();
assert_snapshot!(test.rename("renamed_function"), @r"
info[rename]: Rename symbol (found 5 locations)
--> source.py:2:5
|
2 | def original_function():
| ^^^^^^^^^^^^^^^^^
3 | return 'Hello from source'
|
::: consumer.py:2:20
|
2 | from middle import original_function as func_alias
| -----------------
3 |
4 | def process():
|
::: middle.py:2:20
|
2 | from source import original_function
| -----------------
3 |
4 | def wrapper():
5 | return original_function()
| -----------------
6 |
7 | result = original_function()
| -----------------
|
");
}
#[test]
fn test_rename_alias_in_import_chain() {
let test = CursorTest::builder()
.source(
"file1.py",
"
def func1(): pass
",
)
.source(
"file2.py",
"
from file1 import func1 as func2
func2()
",
)
.source(
"file3.py",
"
from file2 import func2
class App:
def run(self):
return fu<CURSOR>nc2()
",
)
.build();
assert_snapshot!(test.rename("new_util_name"), @r"
info[rename]: Rename symbol (found 4 locations)
--> file3.py:2:19
|
2 | from file2 import func2
| ^^^^^
3 |
4 | class App:
5 | def run(self):
6 | return func2()
| -----
|
::: file2.py:2:28
|
2 | from file1 import func1 as func2
| -----
3 |
4 | func2()
| -----
|
");
}
#[test]
fn test_cannot_rename_keyword() {
// Test that we cannot rename Python keywords like "None"
let test = cursor_test(
"
def process_value(value):
if value is <CURSOR>None:
return 'empty'
return str(value)
",
);
assert_snapshot!(test.prepare_rename(), @"Cannot rename");
}
#[test]
fn test_cannot_rename_builtin_type() {
// Test that we cannot rename Python builtin types like "int"
let test = cursor_test(
"
def convert_to_number(value):
return <CURSOR>int(value)
",
);
assert_snapshot!(test.prepare_rename(), @"Cannot rename");
}
#[test]
fn test_rename_keyword_argument() {
// Test renaming a keyword argument and its corresponding parameter
let test = cursor_test(
"
def func(x, y=5):
return x + y
result = func(10, <CURSOR>y=20)
",
);
assert_snapshot!(test.rename("z"), @r"
info[rename]: Rename symbol (found 3 locations)
--> main.py:2:13
|
2 | def func(x, y=5):
| ^
3 | return x + y
| -
4 |
5 | result = func(10, y=20)
| -
|
");
}
#[test]
fn test_rename_parameter_with_keyword_argument() {
// Test renaming a parameter and its corresponding keyword argument
let test = cursor_test(
"
def func(x, <CURSOR>y=5):
return x + y
result = func(10, y=20)
",
);
assert_snapshot!(test.rename("z"), @r"
info[rename]: Rename symbol (found 3 locations)
--> main.py:2:13
|
2 | def func(x, y=5):
| ^
3 | return x + y
| -
4 |
5 | result = func(10, y=20)
| -
|
");
}
}

View file

@ -18,8 +18,8 @@ pub use python_platform::PythonPlatform;
pub use semantic_model::{Completion, CompletionKind, HasType, NameKind, SemanticModel};
pub use site_packages::{PythonEnvironment, SitePackagesPaths, SysPrefixPathOrigin};
pub use types::ide_support::{
ResolvedDefinition, definitions_for_attribute, definitions_for_imported_symbol,
definitions_for_name, map_stub_definition,
ImportAliasResolution, ResolvedDefinition, definitions_for_attribute,
definitions_for_imported_symbol, definitions_for_name, map_stub_definition,
};
pub use util::diagnostics::add_inferred_python_version_hint_to_diagnostic;

View file

@ -20,7 +20,7 @@ use ruff_python_ast::name::Name;
use ruff_text_size::{Ranged, TextRange};
use rustc_hash::FxHashSet;
pub use resolve_definition::{ResolvedDefinition, map_stub_definition};
pub use resolve_definition::{ImportAliasResolution, ResolvedDefinition, map_stub_definition};
use resolve_definition::{find_symbol_in_scope, resolve_definition};
pub(crate) fn all_declarations_and_bindings<'db>(
@ -517,7 +517,12 @@ pub fn definitions_for_name<'db>(
let mut resolved_definitions = Vec::new();
for definition in &all_definitions {
let resolved = resolve_definition(db, *definition, Some(name_str));
let resolved = resolve_definition(
db,
*definition,
Some(name_str),
ImportAliasResolution::ResolveAliases,
);
resolved_definitions.extend(resolved);
}
@ -528,7 +533,14 @@ pub fn definitions_for_name<'db>(
};
find_symbol_in_scope(db, builtins_scope, name_str)
.into_iter()
.flat_map(|def| resolve_definition(db, def, Some(name_str)))
.flat_map(|def| {
resolve_definition(
db,
def,
Some(name_str),
ImportAliasResolution::ResolveAliases,
)
})
.collect()
} else {
resolved_definitions
@ -577,7 +589,12 @@ pub fn definitions_for_attribute<'db>(
if let Some(module_file) = module_literal.module(db).file(db) {
let module_scope = global_scope(db, module_file);
for def in find_symbol_in_scope(db, module_scope, name_str) {
resolved.extend(resolve_definition(db, def, Some(name_str)));
resolved.extend(resolve_definition(
db,
def,
Some(name_str),
ImportAliasResolution::ResolveAliases,
));
}
}
continue;
@ -613,7 +630,12 @@ pub fn definitions_for_attribute<'db>(
// Check declarations first
for decl in use_def.all_reachable_symbol_declarations(place_id) {
if let Some(def) = decl.declaration.definition() {
resolved.extend(resolve_definition(db, def, Some(name_str)));
resolved.extend(resolve_definition(
db,
def,
Some(name_str),
ImportAliasResolution::ResolveAliases,
));
break 'scopes;
}
}
@ -621,7 +643,12 @@ pub fn definitions_for_attribute<'db>(
// If no declarations found, check bindings
for binding in use_def.all_reachable_symbol_bindings(place_id) {
if let Some(def) = binding.binding.definition() {
resolved.extend(resolve_definition(db, def, Some(name_str)));
resolved.extend(resolve_definition(
db,
def,
Some(name_str),
ImportAliasResolution::ResolveAliases,
));
break 'scopes;
}
}
@ -640,7 +667,12 @@ pub fn definitions_for_attribute<'db>(
// Check declarations first
for decl in use_def.all_reachable_member_declarations(place_id) {
if let Some(def) = decl.declaration.definition() {
resolved.extend(resolve_definition(db, def, Some(name_str)));
resolved.extend(resolve_definition(
db,
def,
Some(name_str),
ImportAliasResolution::ResolveAliases,
));
break 'scopes;
}
}
@ -648,7 +680,12 @@ pub fn definitions_for_attribute<'db>(
// If no declarations found, check bindings
for binding in use_def.all_reachable_member_bindings(place_id) {
if let Some(def) = binding.binding.definition() {
resolved.extend(resolve_definition(db, def, Some(name_str)));
resolved.extend(resolve_definition(
db,
def,
Some(name_str),
ImportAliasResolution::ResolveAliases,
));
break 'scopes;
}
}
@ -715,11 +752,15 @@ pub fn definitions_for_keyword_argument<'db>(
/// Find the definitions for a symbol imported via `from x import y as z` statement.
/// This function handles the case where the cursor is on the original symbol name `y`.
/// Returns the same definitions as would be found for the alias `z`.
/// The `alias_resolution` parameter controls whether symbols imported with local import
/// aliases (like "x" in "from a import b as x") are resolved to their targets or kept
/// as aliases.
pub fn definitions_for_imported_symbol<'db>(
db: &'db dyn Db,
file: File,
import_node: &ast::StmtImportFrom,
symbol_name: &str,
alias_resolution: ImportAliasResolution,
) -> Vec<ResolvedDefinition<'db>> {
let mut visited = FxHashSet::default();
resolve_definition::resolve_from_import_definitions(
@ -728,6 +769,7 @@ pub fn definitions_for_imported_symbol<'db>(
import_node,
symbol_name,
&mut visited,
alias_resolution,
)
}
@ -821,6 +863,15 @@ mod resolve_definition {
//! "resolved definitions". This is done recursively to find the original
//! definition targeted by the import.
/// Controls whether local import aliases should be resolved to their targets or returned as-is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportAliasResolution {
/// Resolve import aliases to their original definitions
ResolveAliases,
/// Keep import aliases as-is, don't resolve to original definitions
PreserveAliases,
}
use indexmap::IndexSet;
use ruff_db::files::{File, FileRange};
use ruff_db::parsed::{ParsedModuleRef, parsed_module};
@ -865,9 +916,16 @@ mod resolve_definition {
db: &'db dyn Db,
definition: Definition<'db>,
symbol_name: Option<&str>,
alias_resolution: ImportAliasResolution,
) -> Vec<ResolvedDefinition<'db>> {
let mut visited = FxHashSet::default();
let resolved = resolve_definition_recursive(db, definition, &mut visited, symbol_name);
let resolved = resolve_definition_recursive(
db,
definition,
&mut visited,
symbol_name,
alias_resolution,
);
// If resolution failed, return the original definition as fallback
if resolved.is_empty() {
@ -883,6 +941,7 @@ mod resolve_definition {
definition: Definition<'db>,
visited: &mut FxHashSet<Definition<'db>>,
symbol_name: Option<&str>,
alias_resolution: ImportAliasResolution,
) -> Vec<ResolvedDefinition<'db>> {
// Prevent infinite recursion if there are circular imports
if visited.contains(&definition) {
@ -928,7 +987,14 @@ mod resolve_definition {
// For `ImportFrom`, we need to resolve the original imported symbol name
// (alias.name), not the local alias (symbol_name)
resolve_from_import_definitions(db, file, import_node, &alias.name, visited)
resolve_from_import_definitions(
db,
file,
import_node,
&alias.name,
visited,
alias_resolution,
)
}
// For star imports, try to resolve to the specific symbol being accessed
@ -939,7 +1005,14 @@ mod resolve_definition {
// If we have a symbol name, use the helper to resolve it in the target module
if let Some(symbol_name) = symbol_name {
resolve_from_import_definitions(db, file, import_node, symbol_name, visited)
resolve_from_import_definitions(
db,
file,
import_node,
symbol_name,
visited,
alias_resolution,
)
} else {
// No symbol context provided, can't resolve star import
Vec::new()
@ -958,7 +1031,21 @@ mod resolve_definition {
import_node: &ast::StmtImportFrom,
symbol_name: &str,
visited: &mut FxHashSet<Definition<'db>>,
alias_resolution: ImportAliasResolution,
) -> Vec<ResolvedDefinition<'db>> {
if alias_resolution == ImportAliasResolution::PreserveAliases {
for alias in &import_node.names {
if let Some(asname) = &alias.asname {
if asname.as_str() == symbol_name {
return vec![ResolvedDefinition::FileWithRange(FileRange::new(
file,
asname.range,
))];
}
}
}
}
// Resolve the target module file
let module_file = {
// Resolve the module being imported from (handles both relative and absolute imports)
@ -987,7 +1074,13 @@ mod resolve_definition {
} else {
let mut resolved_definitions = Vec::new();
for def in definitions_in_module {
let resolved = resolve_definition_recursive(db, def, visited, Some(symbol_name));
let resolved = resolve_definition_recursive(
db,
def,
visited,
Some(symbol_name),
alias_resolution,
);
resolved_definitions.extend(resolved);
}
resolved_definitions

View file

@ -1,11 +1,11 @@
use lsp_types::{
ClientCapabilities, CompletionOptions, DeclarationCapability, DiagnosticOptions,
DiagnosticServerCapabilities, HoverProviderCapability, InlayHintOptions,
InlayHintServerCapabilities, MarkupKind, OneOf, SelectionRangeProviderCapability,
SemanticTokensFullOptions, SemanticTokensLegend, SemanticTokensOptions,
SemanticTokensServerCapabilities, ServerCapabilities, SignatureHelpOptions,
TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
TypeDefinitionProviderCapability, WorkDoneProgressOptions,
InlayHintServerCapabilities, MarkupKind, OneOf, RenameOptions,
SelectionRangeProviderCapability, SemanticTokensFullOptions, SemanticTokensLegend,
SemanticTokensOptions, SemanticTokensServerCapabilities, ServerCapabilities,
SignatureHelpOptions, TextDocumentSyncCapability, TextDocumentSyncKind,
TextDocumentSyncOptions, TypeDefinitionProviderCapability, WorkDoneProgressOptions,
};
use crate::PositionEncoding;
@ -289,6 +289,10 @@ pub(crate) fn server_capabilities(
definition_provider: Some(OneOf::Left(true)),
declaration_provider: Some(DeclarationCapability::Simple(true)),
references_provider: Some(OneOf::Left(true)),
rename_provider: Some(OneOf::Right(RenameOptions {
prepare_provider: Some(true),
work_done_progress_options: WorkDoneProgressOptions::default(),
})),
document_highlight_provider: Some(OneOf::Left(true)),
hover_provider: Some(HoverProviderCapability::Simple(true)),
signature_help_provider: Some(SignatureHelpOptions {

View file

@ -80,6 +80,12 @@ pub(super) fn request(req: server::Request) -> Task {
requests::SignatureHelpRequestHandler::METHOD => background_document_request_task::<
requests::SignatureHelpRequestHandler,
>(req, BackgroundSchedule::Worker),
requests::PrepareRenameRequestHandler::METHOD => background_document_request_task::<
requests::PrepareRenameRequestHandler,
>(req, BackgroundSchedule::Worker),
requests::RenameRequestHandler::METHOD => background_document_request_task::<
requests::RenameRequestHandler,
>(req, BackgroundSchedule::Worker),
requests::CompletionRequestHandler::METHOD => background_document_request_task::<
requests::CompletionRequestHandler,
>(

View file

@ -8,6 +8,8 @@ mod goto_references;
mod goto_type_definition;
mod hover;
mod inlay_hints;
mod prepare_rename;
mod rename;
mod selection_range;
mod semantic_tokens;
mod semantic_tokens_range;
@ -26,6 +28,8 @@ pub(super) use goto_references::ReferencesRequestHandler;
pub(super) use goto_type_definition::GotoTypeDefinitionRequestHandler;
pub(super) use hover::HoverRequestHandler;
pub(super) use inlay_hints::InlayHintRequestHandler;
pub(super) use prepare_rename::PrepareRenameRequestHandler;
pub(super) use rename::RenameRequestHandler;
pub(super) use selection_range::SelectionRangeRequestHandler;
pub(super) use semantic_tokens::SemanticTokensRequestHandler;
pub(super) use semantic_tokens_range::SemanticTokensRangeRequestHandler;

View file

@ -0,0 +1,60 @@
use std::borrow::Cow;
use lsp_types::request::PrepareRenameRequest;
use lsp_types::{PrepareRenameResponse, TextDocumentPositionParams, Url};
use ruff_db::source::{line_index, source_text};
use ty_ide::can_rename;
use ty_project::ProjectDatabase;
use crate::document::{PositionExt, ToRangeExt};
use crate::server::api::traits::{
BackgroundDocumentRequestHandler, RequestHandler, RetriableRequestHandler,
};
use crate::session::DocumentSnapshot;
use crate::session::client::Client;
pub(crate) struct PrepareRenameRequestHandler;
impl RequestHandler for PrepareRenameRequestHandler {
type RequestType = PrepareRenameRequest;
}
impl BackgroundDocumentRequestHandler for PrepareRenameRequestHandler {
fn document_url(params: &TextDocumentPositionParams) -> Cow<Url> {
Cow::Borrowed(&params.text_document.uri)
}
fn run_with_snapshot(
db: &ProjectDatabase,
snapshot: &DocumentSnapshot,
_client: &Client,
params: TextDocumentPositionParams,
) -> crate::server::Result<Option<PrepareRenameResponse>> {
if snapshot
.workspace_settings()
.is_language_services_disabled()
{
return Ok(None);
}
let Some(file) = snapshot.file(db) else {
return Ok(None);
};
let source = source_text(db, file);
let line_index = line_index(db, file);
let offset = params
.position
.to_text_size(&source, &line_index, snapshot.encoding());
let Some(range) = can_rename(db, file, offset) else {
return Ok(None);
};
let lsp_range = range.to_lsp_range(&source, &line_index, snapshot.encoding());
Ok(Some(PrepareRenameResponse::Range(lsp_range)))
}
}
impl RetriableRequestHandler for PrepareRenameRequestHandler {}

View file

@ -0,0 +1,83 @@
use std::borrow::Cow;
use std::collections::HashMap;
use lsp_types::request::Rename;
use lsp_types::{RenameParams, TextEdit, Url, WorkspaceEdit};
use ruff_db::source::{line_index, source_text};
use ty_ide::rename;
use ty_project::ProjectDatabase;
use crate::document::{PositionExt, ToLink};
use crate::server::api::traits::{
BackgroundDocumentRequestHandler, RequestHandler, RetriableRequestHandler,
};
use crate::session::DocumentSnapshot;
use crate::session::client::Client;
pub(crate) struct RenameRequestHandler;
impl RequestHandler for RenameRequestHandler {
type RequestType = Rename;
}
impl BackgroundDocumentRequestHandler for RenameRequestHandler {
fn document_url(params: &RenameParams) -> Cow<Url> {
Cow::Borrowed(&params.text_document_position.text_document.uri)
}
fn run_with_snapshot(
db: &ProjectDatabase,
snapshot: &DocumentSnapshot,
_client: &Client,
params: RenameParams,
) -> crate::server::Result<Option<WorkspaceEdit>> {
if snapshot
.workspace_settings()
.is_language_services_disabled()
{
return Ok(None);
}
let Some(file) = snapshot.file(db) else {
return Ok(None);
};
let source = source_text(db, file);
let line_index = line_index(db, file);
let offset = params.text_document_position.position.to_text_size(
&source,
&line_index,
snapshot.encoding(),
);
let Some(rename_results) = rename(db, file, offset, &params.new_name) else {
return Ok(None);
};
// Group text edits by file
let mut changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
for reference in rename_results {
if let Some(location) = reference.to_location(db, snapshot.encoding()) {
let edit = TextEdit {
range: location.range,
new_text: params.new_name.clone(),
};
changes.entry(location.uri).or_default().push(edit);
}
}
if changes.is_empty() {
return Ok(None);
}
Ok(Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
}))
}
}
impl RetriableRequestHandler for RenameRequestHandler {}

View file

@ -31,6 +31,9 @@ expression: initialization_result
"documentHighlightProvider": true,
"documentSymbolProvider": true,
"workspaceSymbolProvider": true,
"renameProvider": {
"prepareProvider": true
},
"declarationProvider": true,
"semanticTokensProvider": {
"legend": {

View file

@ -31,6 +31,9 @@ expression: initialization_result
"documentHighlightProvider": true,
"documentSymbolProvider": true,
"workspaceSymbolProvider": true,
"renameProvider": {
"prepareProvider": true
},
"declarationProvider": true,
"semanticTokensProvider": {
"legend": {