Repository: palantir/python-language-server

Issue:
Workspace folders not implemented completely
The signature for didChangeWorkspaceFolders

```python
def m_workspace__did_change_workspace_folders(self, added=None, removed=None, **_kwargs):
```

assumes `added` and `removed` are children of the `params` field, but according to the spec they should be nested under `event`? (See https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#workspace_didChangeWorkspaceFolders)


Results in: 
```
:: --> pyls workspace/didChangeWorkspaceFolders: {'event': {'removed': [], 'added': []}}
pyls: 2019-12-19 18:50:04,738 UTC - ERROR - pyls_jsonrpc.endpoint - Failed to handle notification workspace/didChangeWorkspaceFolders: {'event': {'removed': [], 'added': []}}
pyls: Traceback (most recent call last):
pyls: File "/usr/local/lib/python3.7/site-packages/pyls_jsonrpc/endpoint.py", line 142, in _handle_notification
pyls: handler_result = handler(params)
pyls: File "/usr/local/lib/python3.7/site-packages/pyls_jsonrpc/dispatchers.py", line 23, in handler
pyls: return method(**(params or {}))
pyls: File "/usr/local/lib/python3.7/site-packages/pyls/python_ls.py", line 363, in m_workspace__did_change_workspace_folders
pyls: for removed_info in removed:
pyls: TypeError: 'NoneType' object is not iterable
```

The server also does not take the initial list of workspaces from `initialize` - it only creates a single workspace for `rootUri`. Sending a `didChangeWorkspaceFolders` with an entry in `removed` that is not the _rootUri_ will result in a KeyError.


Comments:

Patch:
diff --git a/pyls/python_ls.py b/pyls/python_ls.py
index 577675f..8d6c5f0 100644
--- a/pyls/python_ls.py
+++ b/pyls/python_ls.py
@@ -361,14 +361,37 @@ class PythonLanguageServer(MethodDispatcher):
             for doc_uri in workspace.documents:
                 self.lint(doc_uri, is_saved=False)
 
-    def m_workspace__did_change_workspace_folders(self, added=None, removed=None, **_kwargs):
+    def m_workspace__did_change_workspace_folders(self, event=None, **_kwargs):  # pylint: disable=too-many-locals
+        if event is None:
+            return
+        added = event.get('added', [])
+        removed = event.get('removed', [])
+
         for removed_info in removed:
-            removed_uri = removed_info['uri']
-            self.workspaces.pop(removed_uri)
+            if 'uri' in removed_info:
+                removed_uri = removed_info['uri']
+                self.workspaces.pop(removed_uri, None)
 
         for added_info in added:
-            added_uri = added_info['uri']
-            self.workspaces[added_uri] = Workspace(added_uri, self._endpoint, self.config)
+            if 'uri' in added_info:
+                added_uri = added_info['uri']
+                self.workspaces[added_uri] = Workspace(added_uri, self._endpoint, self.config)
+
+        root_workspace_removed = any(removed_info['uri'] == self.root_uri for removed_info in removed)
+        workspace_added = len(added) > 0 and 'uri' in added[0]
+        if root_workspace_removed and workspace_added:
+            added_uri = added[0]['uri']
+            self.root_uri = added_uri
+            self.workspace = self.workspaces[added_uri]
+        elif root_workspace_removed:
+            # NOTE: Removing the root workspace can only happen when the server
+            # is closed, thus the else condition of this if can never happen.
+            if self.workspaces:
+                log.debug('Root workspace deleted!')
+                available_workspaces = sorted(self.workspaces)
+                first_workspace = available_workspaces[0]
+                self.root_uri = first_workspace
+                self.workspace = self.workspaces[first_workspace]
 
         # Migrate documents that are on the root workspace and have a better
         # match now


Test patch:
diff --git a/test/test_workspace.py b/test/test_workspace.py
index 9b5b7b0..6ecdfbf 100644
--- a/test/test_workspace.py
+++ b/test/test_workspace.py
@@ -96,8 +96,8 @@ def test_multiple_workspaces(tmpdir, pyls):
 
     added_workspaces = [{'uri': path_as_uri(str(x))}
                         for x in (workspace1_dir, workspace2_dir)]
-    pyls.m_workspace__did_change_workspace_folders(
-        added=added_workspaces, removed=[])
+    event = {'added': added_workspaces, 'removed': []}
+    pyls.m_workspace__did_change_workspace_folders(event)
 
     for workspace in added_workspaces:
         assert workspace['uri'] in pyls.workspaces
@@ -116,6 +116,84 @@ def test_multiple_workspaces(tmpdir, pyls):
     workspace2_uri = added_workspaces[1]['uri']
     assert msg['uri'] in pyls.workspaces[workspace2_uri]._docs
 
-    pyls.m_workspace__did_change_workspace_folders(
-        added=[], removed=[added_workspaces[0]])
+    event = {'added': [], 'removed': [added_workspaces[0]]}
+    pyls.m_workspace__did_change_workspace_folders(event)
     assert workspace1_uri not in pyls.workspaces
+
+
+def test_multiple_workspaces_wrong_removed_uri(pyls):
+    workspace = {'uri': 'Test123'}
+    event = {'added': [], 'removed': [workspace]}
+    pyls.m_workspace__did_change_workspace_folders(event)
+    assert workspace['uri'] not in pyls.workspaces
+
+
+def test_root_workspace_changed(pyls):
+    test_uri = 'Test123'
+    pyls.root_uri = test_uri
+    pyls.workspace._root_uri = test_uri
+
+    workspace1 = {'uri': test_uri}
+    workspace2 = {'uri': 'NewTest456'}
+
+    event = {'added': [workspace2], 'removed': [workspace1]}
+    pyls.m_workspace__did_change_workspace_folders(event)
+
+    assert workspace2['uri'] == pyls.workspace._root_uri
+    assert workspace2['uri'] == pyls.root_uri
+
+
+def test_root_workspace_not_changed(pyls):
+    # removed uri != root_uri
+    test_uri_1 = 'Test12'
+    pyls.root_uri = test_uri_1
+    pyls.workspace._root_uri = test_uri_1
+    workspace1 = {'uri': 'Test1234'}
+    workspace2 = {'uri': 'NewTest456'}
+    event = {'added': [workspace2], 'removed': [workspace1]}
+    pyls.m_workspace__did_change_workspace_folders(event)
+    assert test_uri_1 == pyls.workspace._root_uri
+    assert test_uri_1 == pyls.root_uri
+    # empty 'added' list
+    test_uri_2 = 'Test123'
+    new_root_uri = workspace2['uri']
+    pyls.root_uri = test_uri_2
+    pyls.workspace._root_uri = test_uri_2
+    workspace1 = {'uri': test_uri_2}
+    event = {'added': [], 'removed': [workspace1]}
+    pyls.m_workspace__did_change_workspace_folders(event)
+    assert new_root_uri == pyls.workspace._root_uri
+    assert new_root_uri == pyls.root_uri
+    # empty 'removed' list
+    event = {'added': [workspace1], 'removed': []}
+    pyls.m_workspace__did_change_workspace_folders(event)
+    assert new_root_uri == pyls.workspace._root_uri
+    assert new_root_uri == pyls.root_uri
+    # 'added' list has no 'uri'
+    workspace2 = {'TESTuri': 'Test1234'}
+    event = {'added': [workspace2], 'removed': [workspace1]}
+    pyls.m_workspace__did_change_workspace_folders(event)
+    assert new_root_uri == pyls.workspace._root_uri
+    assert new_root_uri == pyls.root_uri
+
+
+def test_root_workspace_removed(tmpdir, pyls):
+    workspace1_dir = tmpdir.mkdir('workspace1')
+    workspace2_dir = tmpdir.mkdir('workspace2')
+    root_uri = pyls.root_uri
+
+    # Add workspaces to the pyls
+    added_workspaces = [{'uri': path_as_uri(str(x))}
+                        for x in (workspace1_dir, workspace2_dir)]
+    event = {'added': added_workspaces, 'removed': []}
+    pyls.m_workspace__did_change_workspace_folders(event)
+
+    # Remove the root workspace
+    removed_workspaces = [{'uri': root_uri}]
+    event = {'added': [], 'removed': removed_workspaces}
+    pyls.m_workspace__did_change_workspace_folders(event)
+
+    # Assert that the first of the workspaces (in alphabetical order) is now
+    # the root workspace
+    assert pyls.root_uri == path_as_uri(str(workspace1_dir))
+    assert pyls.workspace._root_uri == path_as_uri(str(workspace1_dir))