"""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()