Files
clice/tests/conftest.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

177 lines
5.2 KiB
Python

"""Fixtures for clice LSP integration tests using pygls LanguageClient."""
import json
import asyncio
import sys
from pathlib import Path
import pytest
import pytest_asyncio
from lsprotocol.types import (
PROGRESS,
TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS,
WINDOW_WORK_DONE_PROGRESS_CREATE,
ClientCapabilities,
Diagnostic,
InitializeParams,
InitializedParams,
ProgressParams,
PublishDiagnosticsParams,
WorkDoneProgressCreateParams,
WorkspaceFolder,
)
from pygls.lsp.client import BaseLanguageClient
def pytest_addoption(parser: pytest.Parser):
parser.addoption(
"--executable",
required=False,
help="Path to the clice executable.",
)
parser.addoption(
"--mode",
type=str,
choices=["pipe", "socket"],
default="pipe",
help="The connection mode to use.",
)
parser.addoption(
"--host",
type=str,
default="127.0.0.1",
help="The host to connect to (default: 127.0.0.1)",
)
parser.addoption(
"--port",
type=int,
default=50051,
help="The port to connect to",
)
class CliceClient(BaseLanguageClient):
"""Language client that tracks server-sent notifications."""
def __init__(self):
super().__init__("clice-test-client", "0.1.0")
self.diagnostics: dict[str, list[Diagnostic]] = {}
self.diagnostics_events: dict[str, asyncio.Event] = {}
self.progress_tokens: list[str] = []
self.progress_events: list[dict] = []
@self.feature(TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS)
def on_diagnostics(params: PublishDiagnosticsParams):
self.diagnostics[params.uri] = list(params.diagnostics)
if params.uri in self.diagnostics_events:
self.diagnostics_events[params.uri].set()
@self.feature(WINDOW_WORK_DONE_PROGRESS_CREATE)
def on_create_progress(params: WorkDoneProgressCreateParams):
token = str(params.token) if isinstance(params.token, int) else params.token
self.progress_tokens.append(token)
return None
@self.feature(PROGRESS)
def on_progress(params: ProgressParams):
token = str(params.token) if isinstance(params.token, int) else params.token
self.progress_events.append({"token": token, "value": params.value})
def wait_for_diagnostics(self, uri: str) -> asyncio.Event:
"""Get or create an event that fires when diagnostics arrive for uri."""
if uri not in self.diagnostics_events:
self.diagnostics_events[uri] = asyncio.Event()
else:
self.diagnostics_events[uri].clear()
return self.diagnostics_events[uri]
@pytest.fixture(scope="session")
def executable(request) -> Path:
exe = request.config.getoption("--executable")
if not exe:
pytest.skip("--executable not provided")
path = Path(exe)
if sys.platform.startswith("win") and path.suffix.lower() != ".exe":
path_exe = path.with_name(path.name + ".exe")
if path_exe.exists() or not path.exists():
path = path_exe
if not path.exists():
pytest.exit(
f"Error: clice executable not found at '{exe}'. "
"Please ensure the path is correct.",
returncode=64,
)
return path.resolve()
@pytest.fixture(scope="session")
def test_data_dir():
path = Path(__file__).parent / "data"
data_dir = path.resolve()
# Generate compile_commands.json for hello_world
hw_dir = data_dir / "hello_world"
main_cpp = hw_dir / "main.cpp"
cdb_path = hw_dir / "compile_commands.json"
if main_cpp.exists() and not cdb_path.exists():
cdb = [
{
"directory": hw_dir.as_posix(),
"file": main_cpp.as_posix(),
"arguments": [
"clang++",
"-std=c++17",
"-fsyntax-only",
main_cpp.as_posix(),
],
}
]
cdb_path.write_text(json.dumps(cdb, indent=2))
return data_dir
@pytest_asyncio.fixture
async def client(request, executable: Path, test_data_dir: Path):
"""Spawn clice server, yield pygls client, then shutdown+exit."""
config = request.config
mode = config.getoption("--mode")
cmd = [str(executable), "--mode", mode]
if mode == "socket":
host = config.getoption("--host")
port = config.getoption("--port")
cmd += ["--host", host, "--port", str(port)]
c = CliceClient()
await c.start_io(*cmd)
yield c
# Graceful shutdown
try:
await asyncio.wait_for(c.shutdown_async(None), timeout=3.0)
except Exception:
pass
try:
c.exit(None)
except Exception:
pass
# Wait briefly, then force-kill if still running
await asyncio.sleep(0.3)
if hasattr(c, "_server") and c._server is not None and c._server.returncode is None:
c._server.kill()
# Stop pygls client (with timeout to avoid hanging)
try:
c._stop_event.set()
for task in c._async_tasks:
task.cancel()
await asyncio.sleep(0.1)
except Exception:
pass