Repository: PurpleMyst/sansio-lsp-client (language: python)

Issue:
Finish halfbrained's awesome work
- [x] Figure out when hover responses can be null, add test: created #45 


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml
index bc4a3df..a883d44 100644
--- a/.github/workflows/black.yml
+++ b/.github/workflows/black.yml
@@ -15,4 +15,4 @@ jobs:
         pip install poetry && poetry install
     - name: black
       run: |
-        poetry run python3 -m black --check sansio_lsp_client tests.py
+        poetry run python3 -m black --check sansio_lsp_client tests
diff --git a/.gitignore b/.gitignore
index 4546fe6..8bae749 100644
--- a/.gitignore
+++ b/.gitignore
@@ -134,5 +134,6 @@ tags
 
 
 # test stuff
-test_langservers/
 /cov.xml
+/go/
+/tests/langservers/
diff --git a/README.md b/README.md
index d354074..aa05593 100644
--- a/README.md
+++ b/README.md
@@ -2,3 +2,31 @@
 
 An implementation of the client side of the LSP protocol, useful for embedding
 easily in your editor.
+
+
+## Developing
+
+    $ git clone https://github.com/PurpleMyst/sansio-lsp-client
+    $ cd sansio-lsp-client
+    $ python3 -m venv env
+    $ source env/bin/activate
+    (env)$ pip install --upgrade pip
+    (env)$ pip install poetry
+    (env)$ poetry install
+
+Most tests don't work on Windows,
+but GitHub Actions runs tests of all pull requests and uploads coverage files from them.
+TODO: add instructions for looking at coverage files on Windows
+
+To run tests, first download the langservers you need.
+You can mostly read `.github/workflows/test.yml`, but the Go langserver is a bit of a gotcha.
+You will need to install go from https://golang.org/,
+because the one from `sudo apt install golang` is too old.
+Extract it inside where you cloned `sansio-lsp-client`
+so that you get an executable named `sansio-lsp-client/go/bin/go`.
+
+    $ tar xf ~/Downloads/go1.16.5.linux-amd64.tar.gz
+
+Once you have installed all langservers you want, you can run the tests:
+
+    (env)$ PATH="$PATH:$(pwd)/go/bin" poetry run pytest -v


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1970728..91febc7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -15,21 +15,27 @@ jobs:
         with:
           node-version: "12"
       - run: |
-          mkdir test_langservers
+          mkdir tests/langservers
       - run: |
-          cd test_langservers && npm install javascript-typescript-langserver
+          cd tests/langservers && npm install javascript-typescript-langserver
       - shell: bash
         run: |
-          cd test_langservers
+          cd tests/langservers
           for VERSION in 10.0.0 11.0.0; do
             wget "https://github.com/clangd/clangd/releases/download/$VERSION/clangd-linux-$VERSION.zip"
             python3 -m zipfile -e "clangd-linux-$VERSION.zip" .
             chmod +x clangd_$VERSION/bin/clangd
           done
+      - name: Install Go
+        uses: actions/setup-go@v2
+        with:
+          go-version: 1.16.x
+      - run: |
+          cd tests/langservers && GOPATH="$(pwd)" GO111MODULE=on go get golang.org/x/tools/gopls@latest
       - run: |
           pip install poetry && poetry install
       - run: |
-          poetry run pytest --cov=sansio_lsp_client --cov-report=xml:cov.xml -v tests.py
+          poetry run pytest --cov=sansio_lsp_client --cov-report=xml:cov.xml -v
       - uses: actions/upload-artifact@v2
         with:
           name: coverage-report
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..47d37b7
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+addopts = --capture=no
+testpaths = tests/
diff --git a/tests.py b/tests.py
deleted file mode 100644
index a7809da..0000000
--- a/tests.py
+++ /dev/null
@@ -1,197 +0,0 @@
-import contextlib
-import pathlib
-import platform
-import shutil
-import subprocess
-import sys
-import textwrap
-
-import pytest
-
-import sansio_lsp_client as lsp
-
-
-@contextlib.contextmanager
-def run_langserver(project_root, command):
-    with subprocess.Popen(
-        command, stdin=subprocess.PIPE, stdout=subprocess.PIPE
-    ) as process:
-        lsp_client = lsp.Client(trace="verbose", root_uri=project_root.as_uri())
-
-        def make_event_iterator():
-            while True:
-                process.stdin.write(lsp_client.send())
-                process.stdin.flush()
-                # TODO: multiple bytes not work
-                received = process.stdout.read(1)
-                assert received
-                yield from lsp_client.recv(received)
-
-        yield (lsp_client, make_event_iterator())
-
-
-def do_stuff_with_a_langserver(tmp_path, filename, file_content, language_id, command):
-    path = tmp_path / filename
-    path.write_text(file_content)
-
-    with run_langserver(tmp_path, command) as (lsp_client, event_iter):
-        inited = next(event_iter)
-        assert isinstance(inited, lsp.Initialized)
-        lsp_client.did_open(
-            lsp.TextDocumentItem(
-                uri=path.as_uri(), languageId=language_id, text=file_content, version=0
-            )
-        )
-
-        diagnostics = next(event_iter)
-        assert isinstance(diagnostics, lsp.PublishDiagnostics)
-        assert diagnostics.uri == path.as_uri()
-
-        event_id = lsp_client.completions(
-            text_document_position=lsp.TextDocumentPosition(
-                textDocument=lsp.TextDocumentIdentifier(uri=path.as_uri()),
-                position=lsp.Position(
-                    # first line = 0, first column = 0
-                    line=file_content.count("\n"),
-                    character=len(file_content.split("\n")[-1]),
-                ),
-            ),
-            context=lsp.CompletionContext(
-                triggerKind=lsp.CompletionTriggerKind.INVOKED
-            ),
-        )
-        completions = next(event_iter)
-        assert completions.message_id == event_id
-
-        return (diagnostics, completions)
-
-
-def test_pyls(tmp_path):
-    diagnostics, completions = do_stuff_with_a_langserver(
-        tmp_path,
-        "foo.py",
-        textwrap.dedent(
-            """\
-            import sys
-            def do_foo():
-                pass
-            def do_bar():
-                pass
-
-            do_"""
-        ),
-        "python",
-        [sys.executable, "-m", "pyls"],
-    )
-
-    assert [diag.message for diag in diagnostics.diagnostics] == [
-        "'sys' imported but unused",
-        "undefined name 'do_'",
-        "E302 expected 2 blank lines, found 0",
-        "E302 expected 2 blank lines, found 0",
-        "W292 no newline at end of file",
-        "E305 expected 2 blank lines after class or function definition, found 1",
-    ]
-
-    assert [item.label for item in completions.completion_list.items] == [
-        "do_bar()",
-        "do_foo()",
-    ]
-
-
-test_langservers = pathlib.Path(__name__).absolute().parent / "test_langservers"
-
-
-@pytest.mark.skipif(
-    not (test_langservers / "node_modules/.bin/javascript-typescript-stdio").exists(),
-    reason="javascript-typescript-langserver not found",
-)
-@pytest.mark.skipif(shutil.which("node") is None, reason="node not found in $PATH")
-def test_javascript_typescript_langserver(tmp_path):
-    diagnostics, completions = do_stuff_with_a_langserver(
-        tmp_path,
-        "foo.js",
-        textwrap.dedent(
-            """\
-            function doSomethingWithFoo(x, y) {
-                const blah = x + y;
-                return asdf asdf;
-            }
-
-            doS"""
-        ),
-        "javascript",
-        [test_langservers / "node_modules/.bin/javascript-typescript-stdio"],
-    )
-    assert [diag.message for diag in diagnostics.diagnostics] == ["';' expected."]
-    assert "doSomethingWithFoo" in [
-        item.label for item in completions.completion_list.items
-    ]
-
-
-def clangd_decorator(version):
-    def inner(function):
-        function = pytest.mark.skipif(
-            platform.system() == "Windows",
-            reason="don't know how clangd works on windows",
-        )(function)
-        function = pytest.mark.skipif(
-            not list(test_langservers.glob(f"clangd_{version}.*")),
-            reason=f"clangd {version} not found",
-        )(function)
-        return function
-
-    return inner
-
-
-c_args = (
-    "foo.c",
-    textwrap.dedent(
-        """\
-        #include <stdio.h>
-        void do_foo(void) {
-        }
-        int do_bar(char x, long y) {
-            short z = x + y;
-        }
-
-        int main(void) { do_"""
-    ),
-    "c",
-)
-
-
-@clangd_decorator(10)
-def test_clangd_10(tmp_path):
-    diagnostics, completions = do_stuff_with_a_langserver(
-        tmp_path,
-        *c_args,
-        [next(test_langservers.glob("clangd_10.*")) / "bin" / "clangd"],
-    )
-    assert [diag.message for diag in diagnostics.diagnostics] == [
-        "Non-void function does not return a value",
-        "Use of undeclared identifier 'do_'",
-        "Expected '}'\n\nfoo.c:8:16: note: to match this '{'",
-        "To match this '{'\n\nfoo.c:8:21: error: expected '}'",
-    ]
-    completions = [item.label for item in completions.completion_list.items]
-    assert " do_foo()" in completions
-    assert " do_bar(char x, long y)" in completions
-
-
-@clangd_decorator(11)
-def test_clangd_11(tmp_path):
-    diagnostics, completions = do_stuff_with_a_langserver(
-        tmp_path,
-        *c_args,
-        [next(test_langservers.glob("clangd_11.*")) / "bin" / "clangd"],
-    )
-    assert [diag.message for diag in diagnostics.diagnostics] == [
-        "Non-void function does not return a value",
-        "Use of undeclared identifier 'do_'",
-        "Expected '}'\n\nfoo.c:8:16: note: to match this '{'",
-        "To match this '{'\n\nfoo.c:8:21: error: expected '}'",
-    ]
-    completions = [item.label for item in completions.completion_list.items]
-    assert " do_foo()" in completions
-    assert " do_bar(char x, long y)" in completions
diff --git a/tests/test_actual_langservers.py b/tests/test_actual_langservers.py
new file mode 100644
index 0000000..ce67dfe
--- /dev/null
+++ b/tests/test_actual_langservers.py
@@ -0,0 +1,393 @@
+import contextlib
+import functools
+import pprint
+import pathlib
+import shutil
+import subprocess
+import sys
+import textwrap
+import re
+import threading
+import queue
+import time
+
+import pytest
+
+import sansio_lsp_client as lsp
+
+
+# TODO: More stuff coming from halfbrained pull requests
+METHOD_COMPLETION = "completion"
+RESPONSE_TYPES = {METHOD_COMPLETION: lsp.Completion}
+
+
+def find_method_marker(text, method):
+    """ searches for line: `<code> #<method>-<shift>`
+          - example: `sys.getdefaultencoding() #{METHOD_COMPLETION}-5`
+            position returned will be 5 chars before `#...`: `sys.getdefaultencodi | ng()`
+    """
+    match = re.search(rf"\#{method}-(\d+)", text)
+    before_match = text[: match.start()]
+    lineno = before_match.count("\n")
+    column = len(before_match.split("\n")[-1]) - int(match.group(1))
+    return lsp.Position(line=lineno, character=column)
+
+
+class ThreadedServer:
+    """
+    Gathers all messages received from server - to handle random-order-messages
+    that are not a response to a request.
+    """
+
+    def __init__(self, process, root_uri):
+        self.process = process
+        self.root_uri = root_uri
+        self.lsp_client = lsp.Client(root_uri=root_uri, trace="verbose")
+        self.lsp_client._recv_catches_and_logs_errors = False
+        self.msgs = []
+
+        self._pout = process.stdout
+        self._pin = process.stdin
+
+        self._read_q = queue.Queue()
+        self._send_q = queue.Queue()
+
+        self.reader_thread = threading.Thread(
+            target=self._read_loop, name="lsp-reader", daemon=True
+        )
+        self.writer_thread = threading.Thread(
+            target=self._send_loop, name="lsp-writer", daemon=True
+        )
+
+        self.reader_thread.start()
+        self.writer_thread.start()
+
+        self.exception = None
+
+    # threaded
+    def _read_loop(self):
+        try:
+            while True:
+                data = self.process.stdout.read(1)
+
+                if data == b"":
+                    break
+
+                self._read_q.put(data)
+        except Exception as ex:
+            self.exception = ex
+        self._send_q.put_nowait(None)  # stop send loop
+
+    # threaded
+    def _send_loop(self):
+        try:
+            while True:
+                chunk = self._send_q.get()
+                if chunk is None:
+                    break
+
+                # print(f"\nsending: {buf}\n")
+                self.process.stdin.write(chunk)
+                self.process.stdin.flush()
+        except Exception as ex:
+            self.exception = ex
+
+    def _queue_data_to_send(self):
+        send_buf = self.lsp_client.send()
+        if send_buf:
+            self._send_q.put(send_buf)
+
+    def _read_data_received(self):
+        while not self._read_q.empty():
+            data = self._read_q.get()
+            events = self.lsp_client.recv(data)
+            for ev in events:
+                self.msgs.append(ev)
+                self._try_default_reply(ev)
+
+    def _try_default_reply(self, msg):
+        if isinstance(msg, lsp.ShowMessageRequest):
+            msg.reply()
+
+    def wait_for_message_of_type(self, type_, timeout=5):
+        end_time = time.monotonic() + timeout
+        while time.monotonic() < end_time:
+            self._queue_data_to_send()
+            self._read_data_received()
+
+            # raise thread's exception if have any
+            if self.exception:
+                raise self.exception
+
+            for msg in self.msgs:
+                if isinstance(msg, type_):
+                    self.msgs.remove(msg)
+                    return msg
+
+            time.sleep(0.2)
+
+        raise Exception(
+            f"Didn't receive {type_} in time; have: " + pprint.pformat(self.msgs)
+        )
+
+    def exit_cleanly(self):
+        #        if self.msgs:
+        #            # Not necessarily error, gopls sends logging messages for example
+        #            print(
+        #                "* unprocessed messages: " + pprint.pformat(self.msgs)
+        #            )
+
+        assert self.lsp_client.state == lsp.ClientState.NORMAL
+        self.lsp_client.shutdown()
+        self.wait_for_message_of_type(lsp.Shutdown)
+        self.lsp_client.exit()
+        self._queue_data_to_send()
+        self._read_data_received()
+
+    def do_method(self, text, file_uri, method, response_type=None):
+        def doc_pos():
+            return lsp.TextDocumentPosition(
+                textDocument=lsp.TextDocumentIdentifier(uri=file_uri),
+                position=find_method_marker(text, method),
+            )
+
+        if not response_type:
+            response_type = RESPONSE_TYPES[method]
+
+        if method == METHOD_COMPLETION:
+            event_id = self.lsp_client.completions(
+                text_document_position=doc_pos(),
+                context=lsp.CompletionContext(
+                    triggerKind=lsp.CompletionTriggerKind.INVOKED
+                ),
+            )
+        else:
+            raise NotImplementedError(method)
+
+        resp = self.wait_for_message_of_type(response_type)
+        assert not hasattr(resp, "message_id") or resp.message_id == event_id
+        return resp
+
+
+langserver_dir = pathlib.Path(__file__).absolute().parent / "langservers"
+
+
+_clangd_10 = next(langserver_dir.glob("clangd_10.*/bin/clangd"), None)
+_clangd_11 = next(langserver_dir.glob("clangd_11.*/bin/clangd"), None)
+SERVER_COMMANDS = {
+    "pyls": [sys.executable, "-m", "pyls"],
+    "js": [langserver_dir / "node_modules/.bin/javascript-typescript-stdio"],
+    "clangd_10": [_clangd_10],
+    "clangd_11": [_clangd_11],
+    "gopls": [langserver_dir / "bin" / "gopls"],
+}
+
+
+@contextlib.contextmanager
+def start_server(langserver_name, project_root, file_contents):
+    command = SERVER_COMMANDS[langserver_name]
+
+    # Create files before langserver starts
+    for fn, text in file_contents.items():
+        path = project_root / fn
+        path.write_text(text)
+
+    process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
+    tserver = ThreadedServer(process, project_root.as_uri())
+
+    try:
+        yield (tserver, project_root)
+    except Exception as e:
+        # Prevent freezing tests
+        process.kill()
+        raise e
+
+    tserver.exit_cleanly()
+
+
+def check_that_langserver_works(langserver_name, tmp_path):
+    if langserver_name == "pyls":
+        file_contents = {
+            "foo.py": textwrap.dedent(
+                f"""\
+                import sys
+                def do_foo():
+                    sys.getdefaultencoding()
+                def do_bar():
+                    sys.intern("hey")
+
+                do_ #{METHOD_COMPLETION}-1"""
+            )
+        }
+        filename = "foo.py"
+        language_id = "python"
+
+    elif langserver_name == "js":
+        file_contents = {
+            "foo.js": textwrap.dedent(
+                f"""\
+                function doSomethingWithFoo(x, y) {{
+                    const blah = x + y;
+                    return asdf asdf;
+                }}
+
+                doS //#{METHOD_COMPLETION}-3"""
+            )
+        }
+        filename = "foo.js"
+        language_id = "javascript"
+
+    elif langserver_name in ("clangd_10", "clangd_11"):
+        file_contents = {
+            "foo.c": textwrap.dedent(
+                f"""\
+                #include <stdio.h>
+                void do_foo(void);
+                void do_foo(void) {{
+                }}
+                int do_bar(char x, long y) {{
+                    short z = x + y;
+                }}
+
+                int main(void) {{ do_ //#{METHOD_COMPLETION}-3"""
+            )
+        }
+        filename = "foo.c"
+        language_id = "c"
+
+    elif langserver_name == "gopls":
+        file_contents = {
+            "foo.go": textwrap.dedent(
+                f"""\
+                package main
+
+                import "fmt"
+
+                type Creature struct {{
+                    Name string
+                }}
+                func (c*Creature) Dump() {{
+                    fmt.Printf("Name: '%s'", c.Name)
+                }}
+
+                func doSomethingWithFoo(x, y) string {{
+                    blah := x + y
+                    cat := &Creature{{"cat"}}
+                    cat := &Creature{{"cat"}}
+                    cat.Dump()
+                    return asdf asdf
+                }}
+                var s1 = doS //#{METHOD_COMPLETION}-3"""
+            ),
+            "go.mod": textwrap.dedent(
+                """\
+                module example.com/hellp
+
+                go 1.10
+                """
+            ),
+        }
+        filename = "foo.go"
+        language_id = "go"
+    else:
+        raise ValueError(langserver_name)
+
+    with start_server(langserver_name, tmp_path, file_contents) as (
+        tserver,
+        project_root,
+    ):
+        # Initialized #####
+        tserver.wait_for_message_of_type(lsp.Initialized)
+        tserver.lsp_client.did_open(
+            lsp.TextDocumentItem(
+                uri=(project_root / filename).as_uri(),
+                languageId=language_id,
+                text=file_contents[filename],
+                version=0,
+            )
+        )
+
+        # Diagnostics #####
+        diagnostics = tserver.wait_for_message_of_type(lsp.PublishDiagnostics)
+        assert diagnostics.uri == (project_root / filename).as_uri()
+        diag_msgs = [diag.message for diag in diagnostics.diagnostics]
+
+        if langserver_name == "pyls":
+            assert "undefined name 'do_'" in diag_msgs
+            assert "E302 expected 2 blank lines, found 0" in diag_msgs
+            assert "W292 no newline at end of file" in diag_msgs
+        elif langserver_name == "js":
+            assert diag_msgs == ["';' expected."]
+        elif langserver_name in ("clangd_10", "clangd_11"):
+            assert diag_msgs == [
+                "Non-void function does not return a value",
+                "Use of undeclared identifier 'do_'",
+                "Expected '}'\n\nfoo.c:9:16: note: to match this '{'",
+                "To match this '{'\n\nfoo.c:9:37: error: expected '}'",
+            ]
+        elif langserver_name == "gopls":
+            assert diag_msgs == ["expected ';', found asdf"]
+        else:
+            raise ValueError(langserver_name)
+
+        do_method = functools.partial(
+            tserver.do_method,
+            file_contents[filename],
+            (project_root / filename).as_uri(),
+        )
+
+        completions = do_method(METHOD_COMPLETION)
+        completion_labels = [item.label for item in completions.completion_list.items]
+
+        if langserver_name == "pyls":
+            assert completion_labels == ["do_bar()", "do_foo()"]
+        elif langserver_name in ("js", "gopls"):
+            assert "doSomethingWithFoo" in completion_labels
+        elif langserver_name in ("clangd_10", "clangd_11"):
+            assert " do_foo()" in completion_labels
+            assert " do_bar(char x, long y)" in completion_labels
+        else:
+            raise ValueError(langserver_name)
+
+
+def test_pyls(tmp_path):
+    check_that_langserver_works("pyls", tmp_path)
+
+
+@pytest.mark.skipif(
+    not (langserver_dir / "node_modules/.bin/javascript-typescript-stdio").exists(),
+    reason="javascript-typescript-langserver not found",
+)
+@pytest.mark.skipif(shutil.which("node") is None, reason="node not found in $PATH")
+def test_javascript_typescript_langserver(tmp_path):
+    check_that_langserver_works("js", tmp_path)
+
+
+@pytest.mark.skipif(
+    sys.platform == "win32", reason="don't know how clangd works on windows"
+)
+@pytest.mark.skipif(_clangd_10 is None, reason="clangd 10 not found")
+def test_clangd_10(tmp_path):
+    check_that_langserver_works("clangd_10", tmp_path)
+
+
+@pytest.mark.skipif(
+    sys.platform == "win32", reason="don't know how clangd works on windows"
+)
+@pytest.mark.skipif(_clangd_11 is None, reason="clangd 11 not found")
+def test_clangd_11(tmp_path):
+    check_that_langserver_works("clangd_11", tmp_path)
+
+
+@pytest.mark.xfail(
+    strict=True, reason="gopls needs WorkspaceFolders, not implemented yet"
+)
+@pytest.mark.skipif(
+    sys.platform == "win32", reason="don't know how go works on windows"
+)
+@pytest.mark.skipif(
+    not (langserver_dir / "bin" / "gopls").exists(),
+    reason="gopls not installed in tests/langservers/bin/gopls",
+)
+def test_gopls(tmp_path):
+    check_that_langserver_works("gopls", tmp_path)
diff --git a/tests/test_structs.py b/tests/test_structs.py
new file mode 100644
index 0000000..52e8645
--- /dev/null
+++ b/tests/test_structs.py
@@ -0,0 +1,33 @@
+import sansio_lsp_client as lsp
+
+
+def test_change_range():
+    # "foo\nbar2 --> "fOO\nbar"
+    assert lsp.TextDocumentContentChangeEvent.change_range(
+        lsp.Position(line=0, character=1),
+        lsp.Position(line=0, character=3),
+        "OO",
+        "foo\nbar",
+    ) == lsp.TextDocumentContentChangeEvent(
+        range=lsp.Range(
+            start=lsp.Position(line=0, character=1),  # f|oo
+            end=lsp.Position(line=0, character=3),  # foo|
+        ),
+        rangeLength=len("oo"),
+        text="OO",
+    )
+
+    # "foo\nbar\nbaz" --> "foLOLz"
+    assert lsp.TextDocumentContentChangeEvent.change_range(
+        lsp.Position(line=0, character=2),
+        lsp.Position(line=2, character=2),
+        "LOL",
+        "foo\nbar\nbaz",
+    ) == lsp.TextDocumentContentChangeEvent(
+        range=lsp.Range(
+            start=lsp.Position(line=0, character=2),  # fo|o
+            end=lsp.Position(line=2, character=2),  # ba|z
+        ),
+        rangeLength=len("o" + "bar" + "ba"),  # FIXME: include newlines?
+        text="LOL",
+    )