Skip to content

Commit

Permalink
Merge pull request #15 from python-rope/lieryan-implement-rename
Browse files Browse the repository at this point in the history
Implement lsp_rename()
  • Loading branch information
lieryan authored Mar 20, 2024
2 parents 985271e + 8736268 commit 028816b
Show file tree
Hide file tree
Showing 8 changed files with 191 additions and 18 deletions.
39 changes: 28 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,28 +32,45 @@ language server.

## Configuration

There is no configuration yet.
You can enable rename support using pylsp-rope with workspace config key
`pylsp.plugins.pylsp_rope.rename`.

Note that this differs from the config key `pylsp.plugins.rope_rename.enabled`
that is used for the rope rename implementation using the python-lsp-rope's
builtin `rope_rename` plugin. To avoid confusion, avoid enabling more than one
python-lsp-server rename plugin.

## Features

This plugin adds the following features to python-lsp-server:

- extract method (codeAction)
- extract variable (codeAction)
- inline method/variable/parameter (codeAction)
- use function (codeAction)
- method to method object (codeAction)
- convert local variable to field (codeAction)
- organize imports (codeAction)
- introduce parameter (codeAction)
- generate variable/function/class from undefined variable (codeAction)
- more to come...
Rename:

- rename everything: classes, functions, modules, packages (disabled by default)

Code Action:

- extract method
- extract variable
- inline method/variable/parameter
- use function
- method to method object
- convert local variable to field
- organize imports
- introduce parameter
- generate variable/function/class from undefined variable

Refer to [Rope documentation](https://github.com/python-rope/rope/blob/master/docs/overview.rst)
for more details on how these refactoring works.

## Usage

### Rename

When Rename is triggered, rename the symbol under the cursor. If the symbol
under the cursor points to a module/package, it will move that module/package
files.

### Extract method

Variants:
Expand Down
63 changes: 58 additions & 5 deletions pylsp_rope/plugin.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import logging
from typing import List
from typing import List, Optional

from pylsp import hookimpl
from pylsp import hookimpl, uris
from rope.base import libutils
from pylsp.lsp import MessageType
from rope.refactor.rename import Rename

from pylsp_rope import refactoring, typing, commands
from pylsp_rope.project import get_project, get_resource, get_resources
from pylsp_rope.project import (
get_project,
get_resource,
get_resources,
rope_changeset_to_workspace_edit,
new_project,
)


logger = logging.getLogger(__name__)
Expand All @@ -15,7 +23,6 @@
def pylsp_settings():
logger.info("Initializing pylsp_rope")

# Disable default plugins that conflicts with our plugin
return {
"plugins": {
# "autopep8_format": {"enabled": False},
Expand All @@ -35,6 +42,10 @@ def pylsp_settings():
# "references": {"enabled": False},
# "rope_completion": {"enabled": False},
# "rope_rename": {"enabled": False},
"pylsp_rope": {
"enabled": True,
"rename": False,
},
# "signature": {"enabled": False},
# "symbols": {"enabled": False},
# "yapf_format": {"enabled": False},
Expand All @@ -49,7 +60,11 @@ def pylsp_commands(config, workspace) -> List[str]:

@hookimpl
def pylsp_code_actions(
config, workspace, document, range, context
config,
workspace,
document,
range,
context,
) -> List[typing.CodeAction]:
logger.info("textDocument/codeAction: %s %s %s", document, range, context)

Expand Down Expand Up @@ -155,3 +170,41 @@ def pylsp_execute_command(config, workspace, command, arguments):
f"pylsp-rope: {exc}",
msg_type=MessageType.Error,
)


@hookimpl
def pylsp_rename(
config,
workspace,
document,
position,
new_name,
) -> Optional[typing.WorkspaceEdit]:
cfg = config.plugin_settings("pylsp_rope", document_path=document.uri)
if not cfg.get("rename", False):
return None

logger.info("textDocument/rename: %s %s %s", document, position, new_name)
project = new_project(workspace) # FIXME: we shouldn't have to always keep creating new projects here
document, resource = get_resource(workspace, document.uri, project=project)

rename = Rename(
project=project,
resource=resource,
offset=document.offset_at_position(position),
)

logger.debug(
"Executing rename of %s to %s",
document.word_at_position(position),
new_name,
)

rope_changeset = rename.get_changes(new_name, in_hierarchy=True, docs=True)

logger.debug("Finished rename: %s", rope_changeset.changes)
workspace_edit = rope_changeset_to_workspace_edit(
workspace,
rope_changeset,
)
return workspace_edit
24 changes: 22 additions & 2 deletions pylsp_rope/project.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
import logging
from functools import lru_cache
from typing import List, Dict, Tuple, Optional, Literal, cast
Expand All @@ -24,15 +25,34 @@

@lru_cache(maxsize=None)
def get_project(workspace) -> rope.Project:
"""Get a cached rope Project or create one if it doesn't exist yet"""
return new_project(workspace)


def new_project(workspace) -> rope.Project:
"""
Always create a new Project, some operations like rename seems to have
problems when using the cached Project
"""
fscommands = WorkspaceFileCommands(workspace)
return rope.Project(workspace.root_path, fscommands=fscommands)


def get_resource(
workspace, document_uri: DocumentUri
workspace,
document_uri: DocumentUri,
*,
project: rope.Project = None,
) -> Tuple[workspace.Document, rope.Resource]:
"""
Return a Document and Resource related to an LSP Document.
`project` must be provided if not using instances of rope Project from
`pylsp_rope.project.get_project()`.
"""
document = workspace.get_document(document_uri)
resource = libutils.path_to_resource(get_project(workspace), document.path)
project = project or get_project(workspace)
resource = libutils.path_to_resource(project, document.path)
return document, resource


Expand Down
5 changes: 5 additions & 0 deletions test/fixtures/simple_rename.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Test1():
pass

class Test2(Test1):
pass
3 changes: 3 additions & 0 deletions test/fixtures/simple_rename_extra.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from simple_rename import Test1

x = Test1()
3 changes: 3 additions & 0 deletions test/fixtures/simple_rename_extra_result.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from simple_rename import ShouldBeRenamed

x = ShouldBeRenamed()
5 changes: 5 additions & 0 deletions test/fixtures/simple_rename_result.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class ShouldBeRenamed():
pass

class Test2(ShouldBeRenamed):
pass
67 changes: 67 additions & 0 deletions test/test_rename.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import pytest
from pylsp_rope import typing
from pylsp_rope.plugin import pylsp_rename
from pylsp_rope.text import Position
from test.conftest import create_document
from test.helpers import assert_text_edits, assert_modified_documents


@pytest.fixture(autouse=True)
def enable_pylsp_rope_rename_plugin(config):
config._plugin_settings["plugins"]["pylsp_rope"] = {"rename": True}
return config


def test_rope_rename(config, workspace) -> None:
document = create_document(workspace, "simple_rename.py")
extra_document = create_document(workspace, "simple_rename_extra.py")
line = 0
pos = document.lines[line].index("Test1")
position = Position(line, pos)

response: typing.SimpleWorkspaceEdit = pylsp_rename(config, workspace, document, position, "ShouldBeRenamed")
assert len(response.keys()) == 1

assert_modified_documents(response, {document.uri, extra_document.uri})

new_text = assert_text_edits(
response["changes"][document.uri], target="simple_rename_result.py"
)
assert "class ShouldBeRenamed()" in new_text
assert "class Test2(ShouldBeRenamed)" in new_text

new_text = assert_text_edits(
response["changes"][extra_document.uri], target="simple_rename_extra_result.py"
)
assert "from simple_rename import ShouldBeRenamed" in new_text
assert "x = ShouldBeRenamed()" in new_text


def test_rope_rename_disabled(config, workspace) -> None:
document = create_document(workspace, "simple_rename.py")
extra_document = create_document(workspace, "simple_rename_extra.py")
line = 0
pos = document.lines[line].index("Test1")
position = Position(line, pos)

plugin_settings = config.plugin_settings("pylsp_rope", document.uri)
plugin_settings["rename"] = False

response: typing.SimpleWorkspaceEdit = pylsp_rename(config, workspace, document, position, "ShouldBeRenamed")

assert response is None


def test_rope_rename_missing_key(config, workspace) -> None:
document = create_document(workspace, "simple_rename.py")
extra_document = create_document(workspace, "simple_rename_extra.py")
line = 0
pos = document.lines[line].index("Test1")
position = Position(line, pos)

plugin_settings = config.plugin_settings("pylsp_rope", document.uri)
del plugin_settings["rename"]

response: typing.SimpleWorkspaceEdit = pylsp_rename(config, workspace, document, position, "ShouldBeRenamed")

assert response is None

0 comments on commit 028816b

Please sign in to comment.