@@ -0,0 +1,164 @@
|
||||
"""Opt-in real Docker/LSP checks: MODULUS_LSP_TEST_ROOTS=/repo:/other/repo."""
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import urllib.parse
|
||||
|
||||
class lsp_session:
|
||||
def __init__(self, root):
|
||||
command = ["python3", "tools/clangd.py"] if (root / "modulus/cli.py").is_file() else ["python3", "modulus.py", "clangd"]
|
||||
self.log = tempfile.TemporaryFile(mode="w+b")
|
||||
self.process = subprocess.Popen(command, cwd=root, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.log)
|
||||
self.messages = queue.Queue()
|
||||
self.reader = threading.Thread(target=self.read_messages, daemon=True)
|
||||
self.reader.start()
|
||||
self.next_id = 0
|
||||
self.pending = []
|
||||
|
||||
def read_messages(self):
|
||||
try:
|
||||
while True:
|
||||
line = self.process.stdout.readline()
|
||||
if not line:
|
||||
raise EOFError("LSP stream closed")
|
||||
headers = {}
|
||||
while line != b"\r\n":
|
||||
name, value = line.decode().strip().split(":", 1)
|
||||
headers[name.lower()] = value.strip()
|
||||
line = self.process.stdout.readline()
|
||||
if not line:
|
||||
raise EOFError("incomplete LSP header")
|
||||
self.messages.put(json.loads(self.process.stdout.read(int(headers["content-length"]))))
|
||||
except Exception as error:
|
||||
self.messages.put(error)
|
||||
|
||||
def send(self, message):
|
||||
body = json.dumps({"jsonrpc": "2.0", **message}).encode()
|
||||
self.process.stdin.write(f"Content-Length: {len(body)}\r\n\r\n".encode() + body)
|
||||
self.process.stdin.flush()
|
||||
|
||||
def wait(self, predicate, timeout=90):
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
for index, message in enumerate(self.pending):
|
||||
if predicate(message):
|
||||
return self.pending.pop(index)
|
||||
try:
|
||||
message = self.messages.get(timeout=max(0, deadline - time.monotonic()))
|
||||
except queue.Empty:
|
||||
raise AssertionError("LSP response timed out") from None
|
||||
if isinstance(message, Exception):
|
||||
self.log.seek(0)
|
||||
raise AssertionError(f"{message}\n{self.log.read().decode(errors='replace')}")
|
||||
self.pending.append(message)
|
||||
|
||||
def request(self, method, params):
|
||||
self.next_id += 1
|
||||
self.send({"id": self.next_id, "method": method, "params": params})
|
||||
response = self.wait(lambda message: message.get("id") == self.next_id)
|
||||
if "error" in response:
|
||||
raise AssertionError(response["error"])
|
||||
return response.get("result")
|
||||
|
||||
def initialize(self, root):
|
||||
result = self.request("initialize", {"processId": os.getpid(), "rootUri": root.as_uri(),
|
||||
"capabilities": {"textDocument": {"publishDiagnostics": {"versionSupport": True}}}})
|
||||
self.send({"method": "initialized", "params": {}})
|
||||
return result
|
||||
|
||||
def open(self, path, text):
|
||||
uri = path.as_uri()
|
||||
self.send({"method": "textDocument/didOpen", "params": {"textDocument": {
|
||||
"uri": uri, "languageId": "cpp", "version": 1, "text": text}}})
|
||||
return self.wait(lambda message: message.get("method") == "textDocument/publishDiagnostics"
|
||||
and message["params"]["uri"] == uri)["params"]["diagnostics"]
|
||||
|
||||
def close(self):
|
||||
if self.process.poll() is None:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=25)
|
||||
self.process.stdin.close()
|
||||
self.reader.join(timeout=5)
|
||||
self.process.stdout.close()
|
||||
self.log.close()
|
||||
|
||||
@unittest.skipUnless(os.environ.get("MODULUS_LSP_TEST_ROOTS"), "requires prepared Docker images and databases")
|
||||
class container_clangd_test(unittest.TestCase):
|
||||
def roots(self):
|
||||
return [pathlib.Path(root).resolve() for root in os.environ["MODULUS_LSP_TEST_ROOTS"].split(os.pathsep)]
|
||||
|
||||
@unittest.skipUnless(shutil.which("nvim"), "Neovim is not installed")
|
||||
def test_neovim_attachment_and_readonly_navigation(self):
|
||||
script = pathlib.Path(__file__).with_name("nvim_clangd.lua")
|
||||
for root in self.roots():
|
||||
database = json.loads((root / "build/build_x86_debug/compile_commands.json").read_text())
|
||||
source = next(pathlib.Path(entry["file"]) for entry in database
|
||||
if pathlib.Path(entry["file"]).is_relative_to(root / "src"))
|
||||
sources = [(source, "cstdint")]
|
||||
if (root / "src/vamp_pico/main.cpp").exists():
|
||||
sources.append((root / "src/vamp_pico/main.cpp", "pico/stdlib.h"))
|
||||
for source, header in sources:
|
||||
with self.subTest(repo=root.name, source=str(source)):
|
||||
env = {**os.environ, "MODULUS_LSP_REPO": str(root), "MODULUS_LSP_SOURCE": str(source.relative_to(root)),
|
||||
"MODULUS_LSP_HEADER": header}
|
||||
result = subprocess.run(["nvim", "--headless", "-i", "NONE", "-u", str(script)], cwd=root,
|
||||
env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("MODULUS_NVIM_OK", result.stdout)
|
||||
|
||||
def test_native_and_pico_diagnostics_and_local_header_definitions(self):
|
||||
for root in self.roots():
|
||||
with self.subTest(repo=root.name):
|
||||
session = lsp_session(root)
|
||||
try:
|
||||
initialized = session.initialize(root)
|
||||
self.assertIn("19.", initialized["serverInfo"]["version"])
|
||||
database = json.loads((root / "build/build_x86_debug/compile_commands.json").read_text())
|
||||
source = next(pathlib.Path(entry["file"]) for entry in database
|
||||
if pathlib.Path(entry["file"]).is_relative_to(root / "src"))
|
||||
sources = [(source, "cstdint")]
|
||||
if (root / "src/vamp_pico").is_dir():
|
||||
sources.extend((path, "pico/stdlib.h") for path in sorted((root / "src/vamp_pico").glob("*.cpp")))
|
||||
for source, header in sources:
|
||||
with self.subTest(source=str(source)):
|
||||
text = f"#include <{header}>\n" + source.read_text()
|
||||
diagnostics = session.open(source, text)
|
||||
self.assertEqual([item for item in diagnostics if item.get("severity") == 1], [])
|
||||
definitions = session.request("textDocument/definition", {
|
||||
"textDocument": {"uri": source.as_uri()}, "position": {"line": 0, "character": 12}})
|
||||
self.assertTrue(definitions)
|
||||
uri = definitions[0].get("uri", definitions[0].get("targetUri"))
|
||||
path = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(uri).path))
|
||||
self.assertIn("/modulus/clangd/", str(path))
|
||||
self.assertTrue(path.is_file(), str(path))
|
||||
self.assertEqual(path.stat().st_mode & 0o222, 0)
|
||||
session.request("shutdown", None)
|
||||
session.send({"method": "exit"})
|
||||
session.process.stdin.close()
|
||||
self.assertEqual(session.process.wait(timeout=25), 0)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def test_eof_and_termination_remove_container(self):
|
||||
def containers():
|
||||
return set(subprocess.check_output(["docker", "ps", "-q", "--filter", "name=modulus-clangd-"], text=True).split())
|
||||
before = containers()
|
||||
for terminate in (False, True):
|
||||
session = lsp_session(self.roots()[0])
|
||||
try:
|
||||
session.initialize(self.roots()[0])
|
||||
if terminate:
|
||||
session.process.terminate()
|
||||
else:
|
||||
session.process.stdin.close()
|
||||
session.process.wait(timeout=25)
|
||||
self.assertEqual(containers() - before, set())
|
||||
finally:
|
||||
session.close()
|
||||
Reference in New Issue
Block a user