Clangd
publish / publish (push) Successful in 11m40s

This commit is contained in:
khodowany
2026-09-11 00:01:55 -05:00
parent 940fead905
commit b9d30fb975
17 changed files with 863 additions and 28 deletions
+71
View File
@@ -0,0 +1,71 @@
-- Explicit test loading leaves Neovim's persistent exrc trust database unchanged.
if vim.env.MODULUS_NVIM_USER_CONFIG == "1" then
dofile(vim.fn.stdpath("config") .. "/init.lua")
require("lazy").load({ plugins = { "nvim-lspconfig" } })
else
vim.lsp.config("clangd", { cmd = { "clangd" }, filetypes = { "c", "cpp" }, capabilities = { offsetEncoding = { "utf-16" } } })
end
vim.o.exrc = false
local root = assert(vim.env.MODULUS_LSP_REPO)
local attached = false
local previous = vim.lsp.config.clangd.on_attach
local on_attach = function(client, buffer)
attached = true
if previous then previous(client, buffer) end
end
vim.lsp.config("clangd", { on_attach = on_attach, capabilities = { modulus_test = true } })
local original_cmd = vim.lsp.config.clangd.cmd
dofile(root .. "/.nvim.lua")
assert(vim.lsp.config.clangd.on_attach == on_attach)
assert(vim.lsp.config.clangd.capabilities.modulus_test)
local rpc_start = vim.lsp.rpc.start
local fallback
vim.lsp.rpc.start = function(command) fallback = command end
vim.lsp.config.clangd.cmd({}, { root_dir = "/tmp/not-a-modulus-project" })
vim.lsp.rpc.start = rpc_start
assert(vim.deep_equal(fallback, original_cmd), "non-Modulus server command changed")
vim.schedule(function()
local ok, error = pcall(function()
vim.cmd.edit(root .. "/" .. assert(vim.env.MODULUS_LSP_SOURCE))
assert(vim.wait(60000, function()
local clients = vim.lsp.get_clients({ bufnr = 0, name = "clangd" })
return #clients == 1 and clients[1].initialized and attached
end, 50), "container clangd did not attach")
local clients = vim.lsp.get_clients({ bufnr = 0, name = "clangd" })
assert(#clients == 1, "duplicate clangd clients")
local client = clients[1]
assert(client.config._modulus_container, "not the container server")
assert(client.config.root_dir == root, "wrong root")
local header = vim.env.MODULUS_LSP_HEADER or "cstdint"
local changed = false
local notification = vim.api.nvim_create_autocmd("LspNotify", {
callback = function(event)
if event.data.method == "textDocument/didChange" then changed = true end
end,
})
vim.api.nvim_buf_set_lines(0, 0, 0, false, { "#include <" .. header .. ">" })
assert(vim.wait(5000, function() return changed end, 10), "buffer change was not sent")
vim.api.nvim_del_autocmd(notification)
local result = assert(client:request_sync("textDocument/definition", {
textDocument = { uri = vim.uri_from_bufnr(0) }, position = { line = 0, character = 12 },
}, 60000, 0))
assert(result.result and #result.result > 0, vim.inspect(result))
local errors = vim.diagnostic.get(0, { severity = vim.diagnostic.severity.ERROR })
assert(#errors == 0, vim.inspect(errors))
local location = result.result[1]
local target = vim.uri_to_fname(location.uri or location.targetUri)
assert(target:find("/modulus/clangd/", 1, true), target)
vim.lsp.util.show_document(location, client.offset_encoding, { focus = true })
assert(vim.bo.readonly and not vim.bo.modifiable, "cached header is writable")
assert(#vim.lsp.get_clients({ name = "clangd" }) == 1, "navigation spawned another clangd")
print("MODULUS_NVIM_OK " .. root .. " " .. target)
client:stop()
vim.wait(20000, function() return client:is_stopped() end, 50)
end)
if not ok then
print("MODULUS_NVIM_FAILURE " .. tostring(error))
vim.cmd("cquit 1")
end
vim.cmd("qa!")
end)
+161
View File
@@ -0,0 +1,161 @@
import contextlib
import io
import json
import os
import pathlib
import subprocess
import tarfile
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import modulus.clangd as clangd
import modulus.cli as cli
import modulus.config as config
import modulus.export as export
class clangd_test(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = pathlib.Path(temporary.name)
self.context = cli.build_context(self.root, config.normalize({"package": {"name": "sample"}}))
self.context.dockerfile.parent.mkdir(parents=True)
self.context.dockerfile.write_text("FROM sample\n")
self.image = "sha256:" + "1" * 64
def test_defaults_and_custom_settings(self):
self.assertEqual(clangd.settings(self.context)["executable"], "/usr/bin/clangd-19")
self.assertIn("/usr/bin/g++-12", clangd.settings(self.context)["query_drivers"])
custom = {"executable": "/opt/bin/clangd", "arguments": [], "query_drivers": ["/opt/bin/g++"], "header_roots": ["/opt/sdk"]}
result = config.normalize({"package": {"name": "sample"}, "tooling": {"lsp": {"clangd": custom}}})
self.assertEqual(result["tooling"]["lsp"]["clangd"], custom)
def test_config_rejects_unsafe_or_unknown_settings(self):
for invalid in ([], {"unknown": True}, {"executable": "clangd"}, {"arguments": "--background-index"},
{"query_drivers": ["/usr/bin/*"]}, {"header_roots": ["/"]},
{"header_roots": ["/usr/../etc"]}, {"query_drivers": ["/bin/g++,/bin/gcc"]},
{"arguments": ["--query-driver=*"]}, {"arguments": ["--path-mappings=/=/"]}):
with self.subTest(invalid=invalid), self.assertRaises(config.config_error):
config.normalize({"package": {"name": "sample"}, "tooling": {"lsp": {"clangd": invalid}}})
def test_image_fingerprint_and_architecture(self):
image = {"Id": self.image, "Architecture": "amd64", "Config": {"Labels": {clangd.image_label: clangd.recipe_digest(self.context)}}}
with patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0, json.dumps([image]))):
self.assertEqual(clangd.current_image(self.context), self.image)
self.assertIsNone(clangd.current_image(self.context, "arm64"))
self.context.dockerfile.write_text("FROM changed\n")
self.assertIsNone(clangd.current_image(self.context))
def test_cache_is_shared_by_image_and_header_settings_not_repo(self):
cache = clangd.cache_directory(self.context, self.image)
other = cli.build_context(self.root / "other", self.context.config)
self.assertEqual(cache, clangd.cache_directory(other, self.image))
self.context.lsp_settings["clangd"]["arguments"].append("--log=error")
self.assertEqual(cache, clangd.cache_directory(self.context, self.image))
self.context.lsp_settings["clangd"]["header_roots"].append("/opt/sdk")
self.assertNotEqual(cache, clangd.cache_directory(self.context, self.image))
self.assertNotEqual(cache, clangd.cache_directory(other, "sha256:" + "2" * 64))
def test_stdio_launch_has_no_tty_socket_credentials_or_host_home(self):
command = clangd.launch_command(self.context, [], self.image, self.root / "cache", ["/usr/include"], "test-server")
self.assertIn("--interactive", command)
self.assertIn("--read-only", command)
self.assertIn(f"{self.root}:{self.root}:ro", command)
self.assertIn(f"{self.context.build_root}:{self.context.build_root}:rw", command)
self.assertIn("--network", command)
self.assertNotIn("-t", command)
for unwanted in ("--tty", "--volumes-from", "CONAN_TOKEN", "/var/run/docker.sock", str(pathlib.Path.home()) + ":"):
self.assertNotIn(unwanted, " ".join(command))
self.assertIn("--query-driver=/usr/bin/gcc-12,/usr/bin/g++-12,/usr/bin/aarch64-linux-gnu-gcc,/usr/bin/aarch64-linux-gnu-g++", command)
def test_startup_failure_does_not_prepare_and_keeps_stdout_clean(self):
output, errors = io.StringIO(), io.StringIO()
with patch.object(clangd, "current_image", return_value=None), patch.object(clangd, "prepare") as prepare, \
patch.object(cli, "ensure_build_image") as build, contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors):
self.assertEqual(clangd.launch(self.context, []), 1)
prepare.assert_not_called()
build.assert_not_called()
self.assertEqual(output.getvalue(), "")
self.assertIn("configure", errors.getvalue())
def test_path_mapping_uses_client_to_server_direction_and_specificity(self):
self.assertEqual(clangd.path_mappings(pathlib.Path("/cache"), ["/usr/include", "/opt/sdk"]),
"--path-mappings=/cache/rootfs/usr/include=/usr/include,/cache/rootfs/opt/sdk=/opt/sdk")
def archive(self, name, kind=tarfile.REGTYPE):
stream = io.BytesIO()
with tarfile.open(fileobj=stream, mode="w") as archive:
member = tarfile.TarInfo(name)
member.type = kind
member.linkname = "/etc/passwd" if kind == tarfile.SYMTYPE else ""
archive.addfile(member)
stream.seek(0)
return stream
def test_header_extraction_rejects_escape_and_links(self):
for name, kind in (("../bad", tarfile.REGTYPE), ("/usr/include/bad", tarfile.REGTYPE),
("usr/include/../bad", tarfile.REGTYPE), ("etc/passwd", tarfile.REGTYPE),
("usr/include/link", tarfile.SYMTYPE)):
with self.subTest(name=name), self.assertRaises(ValueError):
clangd.extract_headers(self.archive(name, kind), self.root, ["/usr/include"])
clangd.extract_headers(self.archive("usr/include/header.h"), self.root, ["/usr/include"])
self.assertEqual((self.root / "usr/include/header.h").stat().st_mode & 0o777, 0o444)
def test_completed_cache_never_invokes_container_again(self):
cache = self.root / "cache"
(cache / "rootfs/usr/include").mkdir(parents=True)
(cache / "manifest.json").write_text(json.dumps({"version": clangd.cache_version, "roots": ["/usr/include"]}))
with patch.object(clangd, "cache_directory", return_value=cache), patch.object(clangd, "current_image", return_value=self.image), \
patch.object(subprocess, "run") as run:
self.assertEqual(clangd.prepare(self.context), 0)
run.assert_not_called()
def test_failed_extraction_never_publishes_partial_cache(self):
cache = self.root / "image/cache"
process = MagicMock()
process.__enter__.return_value = process
process.stdout = self.archive("../escape")
with patch.object(clangd, "cache_directory", return_value=cache), patch.object(clangd, "current_image", return_value=self.image), \
patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0, '["/usr/include"]')), \
patch.object(subprocess, "Popen", return_value=process), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(clangd.prepare(self.context), 1)
self.assertFalse(cache.exists())
self.assertEqual(list(cache.parent.glob(".headers-*")), [])
def test_cleanup_always_waits_for_child_even_if_docker_removal_times_out(self):
process = MagicMock()
process.wait.side_effect = [0, subprocess.TimeoutExpired("docker", 5), 0]
with patch.object(subprocess, "Popen", return_value=process), \
patch.object(subprocess, "run", side_effect=subprocess.TimeoutExpired("docker rm", 15)), \
contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(clangd.run_server(["docker"], "test-server"), 0)
process.kill.assert_called_once()
self.assertEqual(process.wait.call_count, 3)
def test_nvim_generation_is_stable_opt_out_and_respects_ownership(self):
export.sync_nvim_file(self.root, {})
path = self.root / ".nvim.lua"
before = path.stat().st_mtime_ns
export.sync_nvim_file(self.root, {})
self.assertEqual(before, path.stat().st_mtime_ns)
export.sync_nvim_file(self.root, {"enabled": False})
self.assertFalse(path.exists())
path.write_text("-- user settings\n")
with contextlib.redirect_stderr(io.StringIO()):
export.sync_nvim_file(self.root, {})
export.sync_nvim_file(self.root, {"enabled": False})
self.assertEqual(path.read_text(), "-- user settings\n")
def test_lsp_prepares_after_successful_database_refresh_only(self):
with patch.object(cli, "in_docker", return_value=False), patch.object(cli, "docker_run", return_value=0) as run, \
patch.object(clangd, "prepare", return_value=0) as prepare:
self.assertEqual(cli.lsp(self.context, []), 0)
prepare.assert_called_once_with(self.context)
prepare.reset_mock()
run.return_value = 1
self.assertEqual(cli.lsp(self.context, []), 1)
prepare.assert_not_called()
if __name__ == "__main__":
unittest.main()
+164
View File
@@ -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()
+3 -1
View File
@@ -189,7 +189,7 @@ class upload_packages_test(unittest.TestCase):
previous_cwd = pathlib.Path.cwd()
os.chdir(repo)
try:
for command in ("image", "docs", "shell", "clangd", "docker_support"):
for command in ("image", "docs", "shell", "docker_support"):
with self.assertRaisesRegex(SystemExit, "unknown modulus command"):
modulus.cli.main(command)
finally:
@@ -332,6 +332,8 @@ class upload_packages_test(unittest.TestCase):
context.dockerfile,
"--tag",
"sample-build:dev",
"--label",
f"{modulus.cli.modulus.clangd.image_label}={modulus.cli.modulus.clangd.recipe_digest(context)}",
".",
],
)
+1 -1
View File
@@ -263,7 +263,7 @@ class modulus_export_test(unittest.TestCase):
}
config["tooling"] = {"editor": editor}
normalized = modulus.config.normalize(config)
self.assertEqual(normalized["tooling"]["editor"], editor)
self.assertEqual(normalized["tooling"]["editor"], {**editor, "nvim": {"enabled": True}})
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
modulus.export.sync_editor_files(root, normalized)