Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement lsp_rename() #15

Merged
merged 6 commits into from
Mar 20, 2024
Merged
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
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},
lieryan marked this conversation as resolved.
Show resolved Hide resolved
"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