Repository: palantir/python-language-server
Pull request #224 (2018-01-10 15:08:45)

Support custom method dispatchers
Allow plugins to define method dispatchers so they can respond to LSP extension methods.


Patch:
diff --git a/pyls/dispatcher.py b/pyls/dispatcher.py
new file mode 100644
index 0000000..b6a1c37
--- /dev/null
+++ b/pyls/dispatcher.py
@@ -0,0 +1,30 @@
+# Copyright 2017 Palantir Technologies, Inc.
+import re
+
+_RE_FIRST_CAP = re.compile('(.)([A-Z][a-z]+)')
+_RE_ALL_CAP = re.compile('([a-z0-9])([A-Z])')
+
+
+class JSONRPCMethodDispatcher(object):
+    """JSON RPC method dispatcher that calls methods on itself with params."""
+
+    def __getitem__(self, item):
+        """The jsonrpc dispatcher uses getitem to retrieve the RPC method implementation."""
+        method_name = "m_" + _method_to_string(item)
+        if not hasattr(self, method_name):
+            raise KeyError("Cannot find method %s" % method_name)
+        func = getattr(self, method_name)
+
+        def wrapped(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return wrapped
+
+
+def _method_to_string(method):
+    return _camel_to_underscore(method.replace("/", "__").replace("$", ""))
+
+
+def _camel_to_underscore(string):
+    s1 = _RE_FIRST_CAP.sub(r'\1_\2', string)
+    return _RE_ALL_CAP.sub(r'\1_\2', s1).lower()
diff --git a/pyls/hookspecs.py b/pyls/hookspecs.py
index 9daa889..7052c09 100644
--- a/pyls/hookspecs.py
+++ b/pyls/hookspecs.py
@@ -32,6 +32,11 @@ def pyls_definitions(config, workspace, document, position):
     pass
 
 
+@hookspec
+def pyls_dispatchers(config, workspace):
+    pass
+
+
 @hookspec
 def pyls_document_did_open(config, workspace, document):
     pass
diff --git a/pyls/language_server.py b/pyls/language_server.py
index 8c2a647..b898ae1 100644
--- a/pyls/language_server.py
+++ b/pyls/language_server.py
@@ -1,8 +1,7 @@
 # Copyright 2017 Palantir Technologies, Inc.
 import logging
-import re
 import socketserver
-from . import uris
+from . import dispatcher, uris
 from .server import JSONRPCServer
 
 log = logging.getLogger(__name__)
@@ -50,26 +49,7 @@ def start_io_lang_server(rfile, wfile, handler_class):
     server.handle()
 
 
-class MethodJSONRPCServer(JSONRPCServer):
-    """JSONRPCServer that calls methods on itself with params."""
-
-    def __getitem__(self, item):
-        """The jsonrpc dispatcher uses getitem to retrieve the RPC method implementation."""
-        method_name = "m_" + _method_to_string(item)
-        if not hasattr(self, method_name):
-            raise KeyError("Cannot find method %s" % method_name)
-        func = getattr(self, method_name)
-
-        def wrapped(*args, **kwargs):
-            try:
-                return func(*args, **kwargs)
-            except:
-                log.exception("CAUGHT")
-                raise
-        return wrapped
-
-
-class LanguageServer(MethodJSONRPCServer):
+class LanguageServer(dispatcher.JSONRPCMethodDispatcher, JSONRPCServer):
     """ Implementation of the Microsoft VSCode Language Server Protocol
     https://github.com/Microsoft/language-server-protocol/blob/master/versions/protocol-1-x.md
     """
@@ -113,18 +93,3 @@ class LanguageServer(MethodJSONRPCServer):
 
     def m_exit(self, **_kwargs):
         self.exit()
-
-
-_RE_FIRST_CAP = re.compile('(.)([A-Z][a-z]+)')
-_RE_ALL_CAP = re.compile('([a-z0-9])([A-Z])')
-
-
-def _method_to_string(method):
-    return _camel_to_underscore(
-        method.replace("/", "__").replace("$", "")
-    )
-
-
-def _camel_to_underscore(string):
-    s1 = _RE_FIRST_CAP.sub(r'\1_\2', string)
-    return _RE_ALL_CAP.sub(r'\1_\2', s1).lower()
diff --git a/pyls/python_ls.py b/pyls/python_ls.py
index 8bceb6e..1865718 100644
--- a/pyls/python_ls.py
+++ b/pyls/python_ls.py
@@ -18,8 +18,24 @@ class PythonLanguageServer(LanguageServer):
     workspace = None
     config = None
 
+    # Set of method dispatchers to query
+    _dispatchers = []
+
     _pool = multiprocessing.Pool(PLUGGY_RACE_POOL_SIZE)
 
+    def __getitem__(self, item):
+        """Override the method dispatcher to farm out any unknown messages to our plugins."""
+        try:
+            return super(PythonLanguageServer, self).__getitem__(item)
+        except KeyError:
+            log.debug("Checking dispatchers for %s: %s", item, self._dispatchers)
+            for dispatcher in self._dispatchers:
+                try:
+                    return dispatcher.__getitem__(item)
+                except KeyError:
+                    pass
+        raise KeyError("Unknown item %s" % item)
+
     def _hook_caller(self, hook_name):
         return self.config.plugin_manager.subset_hook_caller(hook_name, self.config.disabled_plugins)
 
@@ -56,6 +72,7 @@ class PythonLanguageServer(LanguageServer):
     def initialize(self, root_uri, init_opts, _process_id):
         self.workspace = Workspace(root_uri, lang_server=self)
         self.config = config.Config(root_uri, init_opts)
+        self._dispatchers = self._hook('pyls_dispatchers')
         self._hook('pyls_initialize')
 
     def code_actions(self, doc_uri, range, context):


Test patch:
diff --git a/test/test_dispatcher.py b/test/test_dispatcher.py
new file mode 100644
index 0000000..12bd1d7
--- /dev/null
+++ b/test/test_dispatcher.py
@@ -0,0 +1,21 @@
+# Copyright 2017 Palantir Technologies, Inc.
+import pytest
+from pyls import dispatcher
+
+
+class TestDispatcher(dispatcher.JSONRPCMethodDispatcher):
+
+    def m_test__method(self, **params):
+        return params
+
+
+def test_method_dispatcher():
+    td = TestDispatcher()
+    params = {'hello': 'world'}
+    assert td['test/method'](**params) == params
+
+
+def test_method_dispatcher_missing_method():
+    td = TestDispatcher()
+    with pytest.raises(KeyError):
+        td['test/noMethod']('hello')