Files
clice/tests/integration/test_modules.py
ykiko 6d3b6acc82 feat: initial CompileGraph integration into MasterServer (#376)
## Summary

Initial integration of `CompileGraph` (#375) into `MasterServer`,
enabling basic end-to-end C++20 module support: on-demand PCM building,
dependency-ordered compilation, cascade invalidation on save, and
diagnostic integration.

This is a **first-pass implementation** — the core pipeline works, but
there are known areas for follow-up:

- PCM files go to system temp dir instead of `.clice/cache/`; no disk
cleanup on invalidation
- `run_build_drain` scans imports itself rather than delegating fully to
CompileGraph
- No incremental/partial rebuild (full PCM rebuild on any change)
- Cycle detection is tested at unit level but integration-level coverage
is minimal

## Changes

### Module dependency compilation (`master_server.cpp`)

Before sending a file to the stateful worker, `run_build_drain` now:

1. Scans imports via `scan_precise()` to discover module dependencies
2. Compiles each dep through `compile_graph->compile()`, which
recursively builds transitive PCMs
3. Handles implementation units — `module M;` implicitly needs the
interface PCM
4. Passes all built PCMs to the stateful worker, excluding the file's
own PCM
5. Skips compile on dep failure and resets `build_running` /
`drain_scheduled`
6. Re-lookups iterators after `co_await` to avoid use-after-invalidation

### Cascade invalidation (`didSave` / `didClose`)

- `didSave`: calls `compile_graph->update()` to mark transitive
dependents dirty, removes stale PCM paths, schedules rebuilds for open
dirtied files
- `didClose`: cancels in-flight compilations for the closed file

### Other fixes in this PR

- Debounce timers switched to `shared_ptr` to prevent use-after-free
when `didClose` destroys the timer mid-wait
- `fill_compile_args` returns `bool`; callers handle empty CDB
gracefully
- Adapt all `PositionMapper` call sites to the new `optional` return API
from eventide

## Test plan

- [x] 25 C++ unit tests for CompileGraph (cycles, partial failure,
cancel, update, empty graph)
- [x] 24 C++ integration tests with real clang PCM compilation
- [x] 3 worker-level module tests (BuildPCM, PCM-dependent compile,
multi-module)
- [x] 26 Python LSP integration tests (single module through circular
deps, hover, error diagnostics)
- [x] 371 unit tests + 54 integration tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 20:05:58 +08:00

625 lines
22 KiB
Python

"""Integration tests for C++20 module support through the full LSP server.
These are the Python equivalents of the C++ compile_graph_integration_tests
and module_worker_tests. They test the complete pipeline:
MasterServer -> CompileGraph -> WorkerPool -> stateless/stateful workers.
"""
import asyncio
import json
import shutil
from pathlib import Path
import pytest
from lsprotocol.types import (
ClientCapabilities,
DidCloseTextDocumentParams,
DidOpenTextDocumentParams,
HoverParams,
InitializeParams,
InitializedParams,
Position,
TextDocumentIdentifier,
TextDocumentItem,
WorkspaceFolder,
)
# Directory containing pre-written module source files for each test case.
_DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "modules"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_cdb(workspace: Path, files: list[str], extra_args: list[str] | None = None):
"""Generate compile_commands.json for the given source files."""
_write_cdb_entries(workspace, [(f, extra_args or []) for f in files])
def _write_cdb_entries(workspace: Path, entries: list[tuple[str, list[str]]]):
"""Generate compile_commands.json with per-file extra args.
entries: list of (filename, extra_args) tuples.
"""
cdb = []
for filename, extra in entries:
args = ["clang++", "-std=c++20", "-fsyntax-only"]
args.extend(extra)
args.append((workspace / filename).as_posix())
cdb.append(
{
"directory": workspace.as_posix(),
"file": (workspace / filename).as_posix(),
"arguments": args,
}
)
(workspace / "compile_commands.json").write_text(json.dumps(cdb, indent=2))
async def _init(client, workspace: Path):
"""Initialize the LSP server with a workspace."""
result = await client.initialize_async(
InitializeParams(
capabilities=ClientCapabilities(),
root_uri=workspace.as_uri(),
workspace_folders=[WorkspaceFolder(uri=workspace.as_uri(), name="test")],
)
)
client.initialized(InitializedParams())
# Give the server time to load CDB and scan dependency graph.
# Use a generous sleep to avoid flaky failures on slow CI machines.
await asyncio.sleep(2.0)
return result
def _open(client, workspace: Path, filename: str, version: int = 0):
"""Open a file and return its URI."""
path = workspace / filename
content = path.read_text(encoding="utf-8")
uri = path.as_uri()
client.text_document_did_open(
DidOpenTextDocumentParams(
text_document=TextDocumentItem(
uri=uri, language_id="cpp", version=version, text=content
)
)
)
return uri, content
async def _open_and_wait(client, workspace: Path, filename: str, timeout: float = 60.0):
"""Open a file and wait for compilation diagnostics."""
uri, content = _open(client, workspace, filename)
event = client.wait_for_diagnostics(uri)
await asyncio.wait_for(event.wait(), timeout=timeout)
return uri, content
# ---------------------------------------------------------------------------
# Single module (no dependencies)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_single_module_no_deps(client):
"""A single module with no imports should compile without errors."""
ws = _DATA_DIR / "single_module_no_deps"
_write_cdb(ws, ["mod_a.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "mod_a.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Chained modules (A -> B, open B)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_chained_modules(client):
"""Opening a module that imports another should trigger dependency compilation."""
ws = _DATA_DIR / "chained_modules"
_write_cdb(ws, ["mod_a.cppm", "mod_b.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "mod_b.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Diamond dependency (Base -> Left/Right -> Top)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_diamond_modules(client):
"""Diamond dependency graph should compile correctly."""
ws = _DATA_DIR / "diamond_modules"
_write_cdb(ws, ["base.cppm", "left.cppm", "right.cppm", "top.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "top.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Dotted module name (my.io, my.app)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_dotted_module_name(client):
"""Dotted module names should work correctly."""
ws = _DATA_DIR / "dotted_module_name"
_write_cdb(ws, ["io.cppm", "app.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "app.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Module implementation unit (module M; without export)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_module_implementation_unit(client):
"""A module implementation unit should compile using the interface PCM."""
ws = _DATA_DIR / "module_implementation_unit"
_write_cdb(ws, ["greeter.cppm", "greeter_impl.cpp"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "greeter_impl.cpp")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Consumer file that imports a module (regular .cpp)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_consumer_imports_module(client):
"""A regular .cpp file that imports a module should get PCM deps compiled."""
ws = _DATA_DIR / "consumer_imports_module"
_write_cdb(ws, ["math.cppm", "main.cpp"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "main.cpp")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Module partitions (multiple partitions)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_module_partitions(client):
"""Module partitions should be compiled in correct order."""
ws = _DATA_DIR / "module_partitions"
_write_cdb(ws, ["part_a.cppm", "part_b.cppm", "lib.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "lib.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Partition interface (single partition)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_partition_interface(client):
"""A single partition interface re-exported from primary should compile."""
ws = _DATA_DIR / "partition_interface"
_write_cdb(ws, ["part.cppm", "primary.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "primary.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Partition chain (partition importing another partition)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_partition_chain(client):
"""Partition importing another partition within same module."""
ws = _DATA_DIR / "partition_chain"
_write_cdb(ws, ["types.cppm", "core.cppm", "sys.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "sys.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Re-export (export import)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_re_export(client):
"""Re-exported module symbols should be accessible through the wrapper."""
ws = _DATA_DIR / "re_export"
_write_cdb(ws, ["core.cppm", "wrapper.cppm", "user.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "user.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Export block syntax
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_export_block(client):
"""Module with export block syntax should compile correctly."""
ws = _DATA_DIR / "export_block"
_write_cdb(ws, ["block.cppm", "consumer.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "consumer.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Global module fragment
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_global_module_fragment(client):
"""Module with global module fragment (#include before module decl)."""
ws = _DATA_DIR / "global_module_fragment"
_write_cdb(ws, ["gmf.cppm"], extra_args=["-I", ws.as_posix()])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "gmf.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Private module fragment
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_private_module_fragment(client):
"""Module with private module fragment should compile correctly."""
ws = _DATA_DIR / "private_module_fragment"
_write_cdb(ws, ["priv.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "priv.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Export namespace
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_export_namespace(client):
"""Module with exported namespace should compile correctly."""
ws = _DATA_DIR / "export_namespace"
_write_cdb(ws, ["ns.cppm", "calc.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "calc.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# GMF with include + module import
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_gmf_with_import(client):
"""Module with GMF (#include) + import should compile correctly."""
ws = _DATA_DIR / "gmf_with_import"
_write_cdb_entries(
ws,
[
("base.cppm", []),
("combined.cppm", ["-I", ws.as_posix()]),
],
)
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "combined.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Independent modules (no shared deps)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_independent_modules(client):
"""Two independent modules should each compile without errors."""
ws = _DATA_DIR / "independent_modules"
_write_cdb(ws, ["x.cppm", "y.cppm"])
await _init(client, ws)
uri_x, _ = await _open_and_wait(client, ws, "x.cppm")
diags_x = client.diagnostics.get(uri_x, [])
assert len(diags_x) == 0, f"Expected no diagnostics for X, got: {diags_x}"
uri_y, _ = await _open_and_wait(client, ws, "y.cppm")
diags_y = client.diagnostics.get(uri_y, [])
assert len(diags_y) == 0, f"Expected no diagnostics for Y, got: {diags_y}"
# ---------------------------------------------------------------------------
# Template export
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_template_export(client):
"""Module with exported templates should compile correctly."""
ws = _DATA_DIR / "template_export"
_write_cdb(ws, ["tmpl.cppm", "use_tmpl.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "use_tmpl.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Class export and inheritance across modules
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_class_export_and_inheritance(client):
"""Exported class with cross-module inheritance should compile."""
ws = _DATA_DIR / "class_export_and_inheritance"
_write_cdb(ws, ["shape.cppm", "circle.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "circle.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Save triggers recompilation (close/reopen with new content)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_save_recompile(client, tmp_path):
"""Closing and reopening a modified module file should recompile without errors."""
# This test mutates source files at runtime, so copy data to tmp_path.
src = _DATA_DIR / "save_recompile"
for f in src.iterdir():
if f.is_file():
shutil.copy2(f, tmp_path / f.name)
_write_cdb(tmp_path, ["leaf.cppm", "mid.cppm"])
await _init(client, tmp_path)
# Open and compile Mid (which triggers Leaf PCM build).
mid_uri, _ = await _open_and_wait(client, tmp_path, "mid.cppm")
diags = client.diagnostics.get(mid_uri, [])
assert len(diags) == 0
# Open Leaf and wait for its initial compilation.
leaf_uri, _ = _open(client, tmp_path, "leaf.cppm")
event = client.wait_for_diagnostics(leaf_uri)
await asyncio.wait_for(event.wait(), timeout=60.0)
# Close Leaf, modify on disk, and reopen with new content.
client.text_document_did_close(
DidCloseTextDocumentParams(text_document=TextDocumentIdentifier(uri=leaf_uri))
)
new_content = "export module Leaf;\nexport int leaf() { return 100; }\n"
(tmp_path / "leaf.cppm").write_text(new_content)
# Reopen with new content triggers compilation.
event = client.wait_for_diagnostics(leaf_uri)
client.text_document_did_open(
DidOpenTextDocumentParams(
text_document=TextDocumentItem(
uri=leaf_uri, language_id="cpp", version=1, text=new_content
)
)
)
await asyncio.wait_for(event.wait(), timeout=60.0)
# Should still compile without errors after change.
diags = client.diagnostics.get(leaf_uri, [])
assert len(diags) == 0, f"Expected no diagnostics after save, got: {diags}"
# ---------------------------------------------------------------------------
# Compilation failure (undefined symbol in module)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_module_compile_error(client):
"""A module with an error should produce diagnostics."""
ws = _DATA_DIR / "module_compile_error"
_write_cdb(ws, ["good.cppm", "bad.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "bad.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) > 0, "Expected diagnostics for undefined symbol"
# The error should be on line 2 (0-indexed) where UNDEFINED_SYMBOL is used.
error_diag = diags[0]
assert error_diag.range.start.line == 2, (
f"Expected error on line 2, got line {error_diag.range.start.line}"
)
# Severity 1 = Error in LSP spec.
assert error_diag.severity == 1, (
f"Expected severity Error (1), got {error_diag.severity}"
)
# ---------------------------------------------------------------------------
# Deep chain (5 modules)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_deep_chain(client):
"""A 5-level module chain should compile correctly."""
ws = _DATA_DIR / "deep_chain"
_write_cdb(ws, ["m1.cppm", "m2.cppm", "m3.cppm", "m4.cppm", "m5.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "m5.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Partition with GMF (#include inside global module fragment of partition)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_partition_with_gmf(client):
"""Partition with GMF (#include) should compile correctly."""
ws = _DATA_DIR / "partition_with_gmf"
_write_cdb_entries(
ws,
[
("part_cfg.cppm", ["-I", ws.as_posix()]),
("cfg.cppm", []),
],
)
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "cfg.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Cross-module partition + external import
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_partition_with_external_import(client):
"""Partition importing an external module should compile correctly."""
ws = _DATA_DIR / "partition_with_external_import"
_write_cdb(ws, ["ext.cppm", "part.cppm", "app.cppm"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "app.cppm")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Hover on imported symbol (feature request after module compilation)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_hover_on_imported_symbol(client):
"""Hover on a symbol imported from a module should return info."""
ws = _DATA_DIR / "hover_on_imported_symbol"
_write_cdb(ws, ["defs.cppm", "use.cpp"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "use.cpp")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# Hover on 'magic_number' (line 3, character 11 = start of 'magic_number()')
hover = await client.text_document_hover_async(
HoverParams(
text_document=TextDocumentIdentifier(uri=uri),
position=Position(line=3, character=11),
)
)
assert hover is not None, "Hover on imported symbol should return info"
assert hover.contents is not None
# ---------------------------------------------------------------------------
# Plain C++ file with no modules (compile_graph == null path)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_no_modules_plain_cpp(client):
"""A plain C++ file with no modules should compile normally (no CompileGraph)."""
ws = _DATA_DIR / "no_modules_plain_cpp"
_write_cdb(ws, ["plain.cpp"])
await _init(client, ws)
uri, _ = await _open_and_wait(client, ws, "plain.cpp")
diags = client.diagnostics.get(uri, [])
assert len(diags) == 0, f"Expected no diagnostics, got: {diags}"
# ---------------------------------------------------------------------------
# Circular module dependency (cycle detection)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_circular_module_dependency(client):
"""Circular module imports should not hang the server.
When modules form a cycle (CycA imports CycB, CycB imports CycA),
the CompileGraph's cycle detection should prevent deadlock. The PCM
builds will fail, so the server may skip the final compilation and
never publish diagnostics. The key assertion is that the server
remains responsive — we verify this by successfully performing a
subsequent operation (opening a non-cyclic file).
"""
ws = _DATA_DIR / "circular_module_dependency"
_write_cdb(ws, ["cycle_a.cppm", "cycle_b.cppm", "ok.cppm"])
await _init(client, ws)
# Open a cyclic file — the server should not hang.
_open(client, ws, "cycle_a.cppm")
# Give the server time to attempt (and fail) the cyclic PCM builds.
await asyncio.sleep(5.0)
# Verify the server is still responsive by opening a non-cyclic file.
uri_ok, _ = await _open_and_wait(client, ws, "ok.cppm")
diags = client.diagnostics.get(uri_ok, [])
assert len(diags) == 0, (
f"Non-cyclic module should compile fine after cycle attempt, got: {diags}"
)