Repository: python-lsp/python-lsp-server
Pull request #25 (2021-04-28 21:11:20)

Feature/completion item/resolve
Supersedes https://github.com/palantir/python-language-server/pull/905. Rebase of #1.

- implemented new hook for resolve
- implemented resolve in jedi_completion and rope_completion
- added `eager` option to rope_completion and jedi_completion allow users using clients which cannot yet handle `completionItem/resolve` to restore to eager resolution (inside of `textDocument/completions`)
- added test for resolve and documentation in Jedi
- tested with development version of [JupyterLab-LSP](https://github.com/krassowski/jupyterlab-lsp) client; the client implementation was tested against reference JavaScript/TypeScript server and against the R-languageserver

TODO:
- [ ] document eager for rope

andfoy: @krassowski, any updates on this one?
krassowski: No, sorry not yet. Feel free to pick it up if you need it. Otherwise, I will get to finish it eventually, but through May I am having busy time at Uni and cannot afford to do both maintenance and active development.
andfoy: @krassowski, no worries! I was just checking in to see what was the state of this PR! Have a nice end of semester
krassowski: Sorry it took me so long to get back to this. I tried to use the proposed document-specific caching mechanism but I don't think it is possible as explained in https://github.com/python-lsp/python-lsp-server/pull/25#discussion_r667228788.
andfoy: Hi @krassowski, no worries! I've just left you a suggestion on the review discussion.
andfoy: Hi @krassowski, thanks for your work on this one, I appreciate a lot that you managed to remove that global variable mechanism! I left you a few questions about the behaviour of this new feature.
andfoy: Thanks for this work @krassowski!

Patch:
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index 75439f5..46c1e2e 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -11,6 +11,7 @@ This server can be configured using `workspace/didChangeConfiguration` method. E
 | `pylsp.plugins.jedi_completion.include_params` | `boolean` | Auto-completes methods and classes with tabstops for each parameter. | `true` |
 | `pylsp.plugins.jedi_completion.include_class_objects` | `boolean` | Adds class objects as a separate completion item. | `true` |
 | `pylsp.plugins.jedi_completion.fuzzy` | `boolean` | Enable fuzzy when requesting autocomplete. | `false` |
+| `pylsp.plugins.jedi_completion.eager` | `boolean` | Resolve documentation and detail eagerly. | `false` |
 | `pylsp.plugins.jedi_definition.enabled` | `boolean` | Enable or disable the plugin. | `true` |
 | `pylsp.plugins.jedi_definition.follow_imports` | `boolean` | The goto call will follow imports. | `true` |
 | `pylsp.plugins.jedi_definition.follow_builtin_imports` | `boolean` | If follow_imports is True will decide if it follow builtin imports. | `true` |
diff --git a/pylsp/config/schema.json b/pylsp/config/schema.json
index 6da8e16..0577743 100644
--- a/pylsp/config/schema.json
+++ b/pylsp/config/schema.json
@@ -49,6 +49,11 @@
       "default": false,
       "description": "Enable fuzzy when requesting autocomplete."
     },
+    "pylsp.plugins.jedi_completion.eager": {
+      "type": "boolean",
+      "default": false,
+      "description": "Resolve documentation and detail eagerly."
+    },
     "pylsp.plugins.jedi_definition.enabled": {
       "type": "boolean",
       "default": true,
diff --git a/pylsp/hookspecs.py b/pylsp/hookspecs.py
index ff2925a..736cf93 100644
--- a/pylsp/hookspecs.py
+++ b/pylsp/hookspecs.py
@@ -29,6 +29,11 @@ def pylsp_completions(config, workspace, document, position):
     pass
 
 
+@hookspec(firstresult=True)
+def pylsp_completion_item_resolve(config, workspace, document, completion_item):
+    pass
+
+
 @hookspec
 def pylsp_definitions(config, workspace, document, position):
     pass
diff --git a/pylsp/plugins/jedi_completion.py b/pylsp/plugins/jedi_completion.py
index a04d210..921c663 100644
--- a/pylsp/plugins/jedi_completion.py
+++ b/pylsp/plugins/jedi_completion.py
@@ -37,10 +37,13 @@ _ERRORS = ('error_node', )
 @hookimpl
 def pylsp_completions(config, document, position):
     """Get formatted completions for current code position"""
+    # pylint: disable=too-many-locals
+
     settings = config.plugin_settings('jedi_completion', document_path=document.path)
+    resolve_eagerly = settings.get('eager', False)
     code_position = _utils.position_to_jedi_linecolumn(document, position)
 
-    code_position["fuzzy"] = settings.get("fuzzy", False)
+    code_position['fuzzy'] = settings.get('fuzzy', False)
     completions = document.jedi_script(use_document_path=True).complete(**code_position)
 
     if not completions:
@@ -60,17 +63,37 @@ def pylsp_completions(config, document, position):
         for c in completions
     ]
 
+    # TODO split up once other improvements are merged
     if include_class_objects:
         for c in completions:
             if c.type == 'class':
-                completion_dict = _format_completion(c, False)
+                completion_dict = _format_completion(c, False, resolve=resolve_eagerly)
                 completion_dict['kind'] = lsp.CompletionItemKind.TypeParameter
                 completion_dict['label'] += ' object'
                 ready_completions.append(completion_dict)
 
+    for completion_dict in ready_completions:
+        completion_dict['data'] = {
+            'doc_uri': document.uri
+        }
+
+    # most recently retrieved completion items, used for resolution
+    document.shared_data['LAST_JEDI_COMPLETIONS'] = {
+        # label is the only required property; here it is assumed to be unique
+        completion['label']: (completion, data)
+        for completion, data in zip(ready_completions, completions)
+    }
+
     return ready_completions or None
 
 
+@hookimpl
+def pylsp_completion_item_resolve(completion_item, document):
+    """Resolve formatted completion for given non-resolved completion"""
+    completion, data = document.shared_data['LAST_JEDI_COMPLETIONS'].get(completion_item['label'])
+    return _resolve_completion(completion, data)
+
+
 def is_exception_class(name):
     """
     Determine if a class name is an instance of an Exception.
@@ -121,16 +144,23 @@ def use_snippets(document, position):
             not (expr_type in _ERRORS and 'import' in code))
 
 
-def _format_completion(d, include_params=True):
+def _resolve_completion(completion, d):
+    completion['detail'] = _detail(d)
+    completion['documentation'] = _utils.format_docstring(d.docstring())
+    return completion
+
+
+def _format_completion(d, include_params=True, resolve=False):
     completion = {
         'label': _label(d),
         'kind': _TYPE_MAP.get(d.type),
-        'detail': _detail(d),
-        'documentation': _utils.format_docstring(d.docstring()),
         'sortText': _sort_text(d),
         'insertText': d.name
     }
 
+    if resolve:
+        completion = _resolve_completion(completion, d)
+
     if d.type == 'path':
         path = osp.normpath(d.name)
         path = path.replace('\\', '\\\\')
diff --git a/pylsp/plugins/rope_completion.py b/pylsp/plugins/rope_completion.py
index c1e162a..b73d841 100644
--- a/pylsp/plugins/rope_completion.py
+++ b/pylsp/plugins/rope_completion.py
@@ -13,11 +13,26 @@ log = logging.getLogger(__name__)
 @hookimpl
 def pylsp_settings():
     # Default rope_completion to disabled
-    return {'plugins': {'rope_completion': {'enabled': False}}}
+    return {'plugins': {'rope_completion': {'enabled': False, 'eager': False}}}
+
+
+def _resolve_completion(completion, data):
+    try:
+        doc = data.get_doc()
+    except AttributeError:
+        doc = ""
+    completion['detail'] = '{0} {1}'.format(data.scope or "", data.name)
+    completion['documentation'] = doc
+    return completion
 
 
 @hookimpl
 def pylsp_completions(config, workspace, document, position):
+    # pylint: disable=too-many-locals
+
+    settings = config.plugin_settings('rope_completion', document_path=document.path)
+    resolve_eagerly = settings.get('eager', False)
+
     # Rope is a bit rubbish at completing module imports, so we'll return None
     word = document.word_at_position({
         # The -1 should really be trying to look at the previous word, but that might be quite expensive
@@ -41,22 +56,37 @@ def pylsp_completions(config, workspace, document, position):
     definitions = sorted_proposals(definitions)
     new_definitions = []
     for d in definitions:
-        try:
-            doc = d.get_doc()
-        except AttributeError:
-            doc = None
-        new_definitions.append({
+        item = {
             'label': d.name,
             'kind': _kind(d),
-            'detail': '{0} {1}'.format(d.scope or "", d.name),
-            'documentation': doc or "",
-            'sortText': _sort_text(d)
-        })
+            'sortText': _sort_text(d),
+            'data': {
+                'doc_uri': document.uri
+            }
+        }
+        if resolve_eagerly:
+            item = _resolve_completion(item, d)
+        new_definitions.append(item)
+
+    # most recently retrieved completion items, used for resolution
+    document.shared_data['LAST_ROPE_COMPLETIONS'] = {
+        # label is the only required property; here it is assumed to be unique
+        completion['label']: (completion, data)
+        for completion, data in zip(new_definitions, definitions)
+    }
+
     definitions = new_definitions
 
     return definitions or None
 
 
+@hookimpl
+def pylsp_completion_item_resolve(completion_item, document):
+    """Resolve formatted completion for given non-resolved completion"""
+    completion, data = document.shared_data['LAST_ROPE_COMPLETIONS'].get(completion_item['label'])
+    return _resolve_completion(completion, data)
+
+
 def _sort_text(definition):
     """ Ensure builtins appear at the bottom.
     Description is of format <type>: <module>.<item>
@@ -72,7 +102,7 @@ def _sort_text(definition):
 
 
 def _kind(d):
-    """ Return the VSCode type """
+    """ Return the LSP type """
     MAP = {
         'none': lsp.CompletionItemKind.Value,
         'type': lsp.CompletionItemKind.Class,
diff --git a/pylsp/python_lsp.py b/pylsp/python_lsp.py
index 81efc35..50c6693 100644
--- a/pylsp/python_lsp.py
+++ b/pylsp/python_lsp.py
@@ -162,8 +162,8 @@ class PythonLSPServer(MethodDispatcher):
                 'resolveProvider': False,  # We may need to make this configurable
             },
             'completionProvider': {
-                'resolveProvider': False,  # We know everything ahead of time
-                'triggerCharacters': ['.']
+                'resolveProvider': True,  # We could know everything ahead of time, but this takes time to transfer
+                'triggerCharacters': ['.'],
             },
             'documentFormattingProvider': True,
             'documentHighlightProvider': True,
@@ -250,6 +250,10 @@ class PythonLSPServer(MethodDispatcher):
             'items': flatten(completions)
         }
 
+    def completion_item_resolve(self, completion_item):
+        doc_uri = completion_item.get('data', {}).get('doc_uri', None)
+        return self._hook('pylsp_completion_item_resolve', doc_uri, completion_item=completion_item)
+
     def definitions(self, doc_uri, position):
         return flatten(self._hook('pylsp_definitions', doc_uri, position=position))
 
@@ -296,6 +300,9 @@ class PythonLSPServer(MethodDispatcher):
     def folding(self, doc_uri):
         return flatten(self._hook('pylsp_folding_range', doc_uri))
 
+    def m_completion_item__resolve(self, **completionItem):
+        return self.completion_item_resolve(completionItem)
+
     def m_text_document__did_close(self, textDocument=None, **_kwargs):
         workspace = self._match_uri_to_workspace(textDocument['uri'])
         workspace.rm_document(textDocument['uri'])
diff --git a/pylsp/workspace.py b/pylsp/workspace.py
index 20720c9..a063cab 100644
--- a/pylsp/workspace.py
+++ b/pylsp/workspace.py
@@ -138,6 +138,7 @@ class Document:
         self.path = uris.to_fs_path(uri)
         self.dot_path = _utils.path_to_dot_name(self.path)
         self.filename = os.path.basename(self.path)
+        self.shared_data = {}
 
         self._config = workspace._config
         self._workspace = workspace


Test patch:
diff --git a/test/plugins/test_completion.py b/test/plugins/test_completion.py
index df19724..be138e8 100644
--- a/test/plugins/test_completion.py
+++ b/test/plugins/test_completion.py
@@ -12,6 +12,7 @@ import pytest
 from pylsp import uris, lsp
 from pylsp.workspace import Document
 from pylsp.plugins.jedi_completion import pylsp_completions as pylsp_jedi_completions
+from pylsp.plugins.jedi_completion import pylsp_completion_item_resolve as pylsp_jedi_completion_item_resolve
 from pylsp.plugins.rope_completion import pylsp_completions as pylsp_rope_completions
 from pylsp._utils import JEDI_VERSION
 
@@ -44,6 +45,10 @@ class Hello():
 print Hello().world
 
 print Hello().every
+
+def documented_hello():
+    \"\"\"Sends a polite greeting\"\"\"
+    pass
 """
 
 
@@ -139,6 +144,26 @@ def test_jedi_completion(config, workspace):
     pylsp_jedi_completions(config, doc, {'line': 1, 'character': 1000})
 
 
+def test_jedi_completion_item_resolve(config, workspace):
+    # Over the blank line
+    com_position = {'line': 8, 'character': 0}
+    doc = Document(DOC_URI, workspace, DOC)
+    completions = pylsp_jedi_completions(config, doc, com_position)
+
+    items = {c['label']: c for c in completions}
+
+    documented_hello_item = items['documented_hello()']
+
+    assert 'documentation' not in documented_hello_item
+    assert 'detail' not in documented_hello_item
+
+    resolved_documented_hello = pylsp_jedi_completion_item_resolve(
+        completion_item=documented_hello_item,
+        document=doc
+    )
+    assert 'Sends a polite greeting' in resolved_documented_hello['documentation']
+
+
 def test_jedi_completion_with_fuzzy_enabled(config, workspace):
     # Over 'i' in os.path.isabs(...)
     config.update({'plugins': {'jedi_completion': {'fuzzy': True}}})
@@ -410,7 +435,9 @@ def test_jedi_completion_environment(workspace):
     # After 'import logh' with new environment
     completions = pylsp_jedi_completions(doc._config, doc, com_position)
     assert completions[0]['label'] == 'loghub'
-    assert 'changelog generator' in completions[0]['documentation'].lower()
+
+    resolved = pylsp_jedi_completion_item_resolve(completions[0], doc)
+    assert 'changelog generator' in resolved['documentation'].lower()
 
 
 def test_document_path_completions(tmpdir, workspace_other_root_path):