Repository: palantir/python-language-server (python)

Issue:
Make default renaming work with Jedi
Could the rename functionality be defaulted (if rope is not installed) to jedi's renaming function since it is a base dependency? The readme doesn't list it for jedi.

Pull request description:
Add support for renaming with Jedi
Leverages jedi's support for refactoring to implement rename.

This requires upgrading jedi. ~I can split this PR in two if preferred (first upgrade jedi, then add rename).~ Nevermind I see that #781 exists, once that's merged I can rebase.

**Note:** Jedi refactoring requires Python 3.6+, so I added 3.6 to CI. I didn't remove Python 3.5, but I believe at this point it's probably safe to do so. Thoughts? Also let me know if you'd rather go all the way to Python 3.8.

Closes #530. 

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 7ec7b8a..4beef05 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -14,7 +14,7 @@ jobs:
 
   python3-test:
     docker:
-      - image: "python:3.5-stretch"
+      - image: "python:3.6-stretch"
     steps:
       - checkout
       # To test Jedi environments
@@ -35,7 +35,7 @@ jobs:
 
   publish:
     docker:
-      - image: "python:3.5-stretch"
+      - image: "python:3.6-stretch"
     steps:
       - checkout
       - run: ./scripts/circle/pypi.sh
diff --git a/appveyor.yml b/appveyor.yml
index 69e3e85..4950b8a 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -6,8 +6,8 @@ environment:
       PYTHON_VERSION: "2.7.15"
       PYTHON_ARCH: "64"
 
-    - PYTHON: "C:\\Python35"
-      PYTHON_VERSION: "3.5.7"
+    - PYTHON: "C:\\Python36"
+      PYTHON_VERSION: "3.6.8"
       PYTHON_ARCH: "64"
 
 matrix:
diff --git a/pyls/plugins/jedi_rename.py b/pyls/plugins/jedi_rename.py
new file mode 100644
index 0000000..2e633d7
--- /dev/null
+++ b/pyls/plugins/jedi_rename.py
@@ -0,0 +1,48 @@
+# Copyright 2020 Palantir Technologies, Inc.
+import logging
+
+from pyls import hookimpl, uris, _utils
+
+log = logging.getLogger(__name__)
+
+
+@hookimpl
+def pyls_rename(config, workspace, document, position, new_name):  # pylint: disable=unused-argument
+    log.debug('Executing rename of %s to %s', document.word_at_position(position), new_name)
+    kwargs = _utils.position_to_jedi_linecolumn(document, position)
+    kwargs['new_name'] = new_name
+    try:
+        refactoring = document.jedi_script().rename(**kwargs)
+    except NotImplementedError:
+        raise Exception('No support for renaming in Python 2/3.5 with Jedi. '
+                        'Consider using the rope_rename plugin instead')
+    log.debug('Finished rename: %s', refactoring.get_diff())
+
+    return {
+        'documentChanges': [
+            {
+                'textDocument': {
+                    'uri': uris.uri_with(document.uri, path=file_path),
+                    'version': workspace.get_document(document.uri).version,
+                },
+                'edits': [
+                    {
+                        'range': {
+                            'start': {'line': 0, 'character': 0},
+                            'end': {
+                                'line': _num_lines(changed_file.get_new_code()),
+                                'character': 0,
+                            },
+                        },
+                        'newText': changed_file.get_new_code(),
+                    }
+                ],
+            }
+            for file_path, changed_file in refactoring.get_changed_files().items()
+        ],
+    }
+
+
+def _num_lines(file_contents):
+    'Count the number of lines in the given string.'
+    return len(file_contents.splitlines())
diff --git a/pyls/plugins/rope_rename.py b/pyls/plugins/rope_rename.py
index 3dec315..80091c0 100644
--- a/pyls/plugins/rope_rename.py
+++ b/pyls/plugins/rope_rename.py
@@ -10,6 +10,12 @@ from pyls import hookimpl, uris
 log = logging.getLogger(__name__)
 
 
+@hookimpl
+def pyls_settings():
+    # Default rope_rename to disabled
+    return {'plugins': {'rope_rename': {'enabled': False}}}
+
+
 @hookimpl
 def pyls_rename(config, workspace, document, position, new_name):
     rope_config = config.settings(document_path=document.path).get('rope', {})
diff --git a/setup.py b/setup.py
index d11a2d4..f3c465d 100755
--- a/setup.py
+++ b/setup.py
@@ -87,6 +87,7 @@ setup(
             'jedi_hover = pyls.plugins.hover',
             'jedi_highlight = pyls.plugins.highlight',
             'jedi_references = pyls.plugins.references',
+            'jedi_rename = pyls.plugins.jedi_rename',
             'jedi_signature_help = pyls.plugins.signature',
             'jedi_symbols = pyls.plugins.symbols',
             'mccabe = pyls.plugins.mccabe_lint',


Test patch:
diff --git a/test/fixtures.py b/test/fixtures.py
index 08d57f0..490938a 100644
--- a/test/fixtures.py
+++ b/test/fixtures.py
@@ -1,4 +1,5 @@
 # Copyright 2017 Palantir Technologies, Inc.
+import os
 import sys
 from mock import Mock
 import pytest
@@ -50,3 +51,23 @@ def config(workspace):  # pylint: disable=redefined-outer-name
 @pytest.fixture
 def doc(workspace):  # pylint: disable=redefined-outer-name
     return Document(DOC_URI, workspace, DOC)
+
+
+@pytest.fixture
+def temp_workspace_factory(workspace):  # pylint: disable=redefined-outer-name
+    '''
+    Returns a function that creates a temporary workspace from the files dict.
+    The dict is in the format {"file_name": "file_contents"}
+    '''
+    def fn(files):
+        def create_file(name, content):
+            fn = os.path.join(workspace.root_path, name)
+            with open(fn, 'w') as f:
+                f.write(content)
+            workspace.put_document(uris.from_fs_path(fn), content)
+
+        for name, content in files.items():
+            create_file(name, content)
+        return workspace
+
+    return fn
diff --git a/test/plugins/test_jedi_rename.py b/test/plugins/test_jedi_rename.py
new file mode 100644
index 0000000..1d82d95
--- /dev/null
+++ b/test/plugins/test_jedi_rename.py
@@ -0,0 +1,48 @@
+# Copyright 2020 Palantir Technologies, Inc.
+import os
+import sys
+
+import pytest
+from pyls import uris
+from pyls.plugins.jedi_rename import pyls_rename
+from pyls.workspace import Document
+
+LT_PY36 = sys.version_info.major < 3 or (sys.version_info.major == 3 and sys.version_info.minor < 6)
+
+DOC_NAME = 'test1.py'
+DOC = '''class Test1():
+    pass
+
+class Test2(Test1):
+    pass
+'''
+
+
+@pytest.fixture
+def tmp_workspace(temp_workspace_factory):
+    return temp_workspace_factory({DOC_NAME: DOC})
+
+
+@pytest.mark.skipif(LT_PY36, reason='Jedi refactoring isnt supported on Python 2.x/3.5')
+def test_jedi_rename(tmp_workspace, config):  # pylint: disable=redefined-outer-name
+    # rename the `Test1` class
+    position = {'line': 0, 'character': 6}
+    DOC_URI = uris.from_fs_path(os.path.join(tmp_workspace.root_path, DOC_NAME))
+    doc = Document(DOC_URI, tmp_workspace)
+
+    result = pyls_rename(config, tmp_workspace, doc, position, 'ShouldBeRenamed')
+    assert len(result.keys()) == 1
+
+    changes = result.get('documentChanges')
+    assert len(changes) == 1
+    changes = changes[0]
+
+    assert changes.get('edits') == [
+        {
+            'range': {
+                'start': {'line': 0, 'character': 0},
+                'end': {'line': 5, 'character': 0},
+            },
+            'newText': 'class ShouldBeRenamed():\n    pass\n\nclass Test2(ShouldBeRenamed):\n    pass\n',
+        }
+    ]
diff --git a/test/plugins/test_references.py b/test/plugins/test_references.py
index d7ce40e..a3e5f88 100644
--- a/test/plugins/test_references.py
+++ b/test/plugins/test_references.py
@@ -26,17 +26,11 @@ except UnicodeError:
 
 
 @pytest.fixture
-def tmp_workspace(workspace):
-    def create_file(name, content):
-        fn = os.path.join(workspace.root_path, name)
-        with open(fn, 'w') as f:
-            f.write(content)
-        workspace.put_document(uris.from_fs_path(fn), content)
-
-    create_file(DOC1_NAME, DOC1)
-    create_file(DOC2_NAME, DOC2)
-
-    return workspace
+def tmp_workspace(temp_workspace_factory):
+    return temp_workspace_factory({
+        DOC1_NAME: DOC1,
+        DOC2_NAME: DOC2,
+    })
 
 
 def test_references(tmp_workspace):  # pylint: disable=redefined-outer-name
diff --git a/test/plugins/test_rope_rename.py b/test/plugins/test_rope_rename.py
index 12c6e9f..45bd6bb 100644
--- a/test/plugins/test_rope_rename.py
+++ b/test/plugins/test_rope_rename.py
@@ -15,15 +15,8 @@ class Test2(Test1):
 
 
 @pytest.fixture
-def tmp_workspace(workspace):
-    def create_file(name, content):
-        fn = os.path.join(workspace.root_path, name)
-        with open(fn, "w") as f:
-            f.write(content)
-        workspace.put_document(uris.from_fs_path(fn), content)
-
-    create_file(DOC_NAME, DOC)
-    return workspace
+def tmp_workspace(temp_workspace_factory):
+    return temp_workspace_factory({DOC_NAME: DOC})
 
 
 def test_rope_rename(tmp_workspace, config):  # pylint: disable=redefined-outer-name