Repository: python-lsp/python-lsp-server (python)

Issue:
Workspace folders not initialized properly
The `initialize` request can include a list of workspace folders:

```ts
	/**
	 * The workspace folders configured in the client when the server starts.
	 * This property is only available if the client supports workspace folders.
	 * It can be `null` if the client supports workspace folders but none are
	 * configured.
	 *
	 * @since 3.6.0
	 */
	workspaceFolders?: WorkspaceFolder[] | null;
```

pylsp ignores those during initialize and expects the client to send the [`workspace/didChangeWorkspaceFolders`](https://microsoft.github.io/language-server-protocol/specification#workspace_didChangeWorkspaceFolders) notification to initialize the workspace folders. Expecting that notification is a bug since it only needs to be called when folders change, relative to the ones reported in `initialize`.

Pull request description:
Initialize workspaces from the initialize request
Don't ignore workspace folders provided through the initialize request
expecting that the client sends the workspace/didChangeWorkspaceFolders
notification. Expecting that notification is a bug since it only needs
to be sent when folders change, relative to the ones reported in initialize.

Fixes #48

Patch:
diff --git a/.gitignore b/.gitignore
index ac609b3..4f47251 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,9 @@ __pycache__/
 *.py[cod]
 *$py.class
 
+# Mypy cache
+.mypy_cache/
+
 # IntelliJ
 *.iml
 *.ipr
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/README.md b/README.md
index aa427ab..d0d362e 100644
--- a/README.md
+++ b/README.md
@@ -84,8 +84,8 @@ All configuration options are described in [`CONFIGURATION.md`](https://github.c
 
 To run the test suite:
 
-```
-pip install .[test] && pytest
+```sh
+pip install '.[test]' && pytest
 ```
 
 After adding configuration options to `schema.json`, refresh the `CONFIGURATION.md` file with
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..ec09923 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,
@@ -199,7 +199,8 @@ class PythonLSPServer(MethodDispatcher):
         log.info('Server capabilities: %s', server_capabilities)
         return server_capabilities
 
-    def m_initialize(self, processId=None, rootUri=None, rootPath=None, initializationOptions=None, **_kwargs):
+    def m_initialize(self, processId=None, rootUri=None, rootPath=None,
+                     initializationOptions=None, workspaceFolders=None, **_kwargs):
         log.debug('Language server initialized with %s %s %s %s', processId, rootUri, rootPath, initializationOptions)
         if rootUri is None:
             rootUri = uris.from_fs_path(rootPath) if rootPath is not None else ''
@@ -210,6 +211,19 @@ class PythonLSPServer(MethodDispatcher):
                                     processId, _kwargs.get('capabilities', {}))
         self.workspace = Workspace(rootUri, self._endpoint, self.config)
         self.workspaces[rootUri] = self.workspace
+        if workspaceFolders:
+            for folder in workspaceFolders:
+                uri = folder['uri']
+                if uri == rootUri:
+                    # Already created
+                    continue
+                workspace_config = config.Config(
+                    uri, self.config._init_opts,
+                    self.config._process_id, self.config._capabilities)
+                workspace_config.update(self.config._settings)
+                self.workspaces[uri] = Workspace(
+                    uri, self._endpoint, workspace_config)
+
         self._dispatchers = self._hook('pylsp_dispatchers')
         self._hook('pylsp_initialize')
 
@@ -250,6 +264,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 +314,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/fixtures.py b/test/fixtures.py
index c50c1c1..cce5b32 100644
--- a/test/fixtures.py
+++ b/test/fixtures.py
@@ -34,6 +34,34 @@ def pylsp(tmpdir):
     return ls
 
 
+@pytest.fixture
+def pylsp_w_workspace_folders(tmpdir):
+    """ Return an initialized python LS """
+    ls = PythonLSPServer(StringIO, StringIO)
+
+    folder1 = tmpdir.mkdir('folder1')
+    folder2 = tmpdir.mkdir('folder2')
+
+    ls.m_initialize(
+        processId=1,
+        rootUri=uris.from_fs_path(str(folder1)),
+        initializationOptions={},
+        workspaceFolders=[
+            {
+                'uri': uris.from_fs_path(str(folder1)),
+                'name': 'folder1'
+            },
+            {
+                'uri': uris.from_fs_path(str(folder2)),
+                'name': 'folder2'
+            }
+        ]
+    )
+
+    workspace_folders = [folder1, folder2]
+    return (ls, workspace_folders)
+
+
 @pytest.fixture
 def workspace(tmpdir):
     """Return a workspace."""
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):
diff --git a/test/test_workspace.py b/test/test_workspace.py
index e8851e0..a008e7e 100644
--- a/test/test_workspace.py
+++ b/test/test_workspace.py
@@ -69,6 +69,45 @@ def test_root_project_with_no_setup_py(pylsp):
     assert workspace_root in test_doc.sys_path()
 
 
+def test_multiple_workspaces_from_initialize(pylsp_w_workspace_folders):
+    pylsp, workspace_folders = pylsp_w_workspace_folders
+
+    assert len(pylsp.workspaces) == 2
+
+    folders_uris = [uris.from_fs_path(str(folder)) for folder in workspace_folders]
+
+    for folder_uri in folders_uris:
+        assert folder_uri in pylsp.workspaces
+
+    assert folders_uris[0] == pylsp.root_uri
+
+    # Create file in the first workspace folder.
+    file1 = workspace_folders[0].join('file1.py')
+    file1.write('import os')
+    msg1 = {
+        'uri': path_as_uri(str(file1)),
+        'version': 1,
+        'text': 'import os'
+    }
+
+    pylsp.m_text_document__did_open(textDocument=msg1)
+    assert msg1['uri'] in pylsp.workspace._docs
+    assert msg1['uri'] in pylsp.workspaces[folders_uris[0]]._docs
+
+    # Create file in the second workspace folder.
+    file2 = workspace_folders[1].join('file2.py')
+    file2.write('import sys')
+    msg2 = {
+        'uri': path_as_uri(str(file2)),
+        'version': 1,
+        'text': 'import sys'
+    }
+
+    pylsp.m_text_document__did_open(textDocument=msg2)
+    assert msg2['uri'] not in pylsp.workspace._docs
+    assert msg2['uri'] in pylsp.workspaces[folders_uris[1]]._docs
+
+
 def test_multiple_workspaces(tmpdir, pylsp):
     workspace1_dir = tmpdir.mkdir('workspace1')
     workspace2_dir = tmpdir.mkdir('workspace2')