diff --git a/src/server/master_server.cpp b/src/server/master_server.cpp index 6bface11e..9ee38a3ea 100644 --- a/src/server/master_server.cpp +++ b/src/server/master_server.cpp @@ -308,8 +308,12 @@ void MasterServer::register_handlers() { auto path = uri_to_path(params.text_document.uri); auto path_id = workspace.path_pool.intern(path); - auto [it, _] = sessions.try_emplace(path_id); + auto [it, inserted] = sessions.try_emplace(path_id); auto& session = it->second; + if(!inserted) { + // DenseMap tombstone may retain stale data — reset to a fresh Session. + session = Session{}; + } session.path_id = path_id; session.version = params.text_document.version; session.text = params.text_document.text; diff --git a/tests/conftest.py b/tests/conftest.py index ec039d9e0..a04ee6ca6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,35 +1,13 @@ -"""Fixtures and shared helpers for clice LSP integration tests using pygls LanguageClient.""" - import asyncio import json import shutil import subprocess import sys -from collections.abc import AsyncGenerator from pathlib import Path -from urllib.parse import unquote import pytest -from lsprotocol.types import ( - PROGRESS, - TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS, - WINDOW_WORK_DONE_PROGRESS_CREATE, - ClientCapabilities, - Diagnostic, - DidOpenTextDocumentParams, - HoverParams, - InitializeParams, - InitializeResult, - InitializedParams, - Position, - ProgressParams, - PublishDiagnosticsParams, - TextDocumentIdentifier, - TextDocumentItem, - WorkDoneProgressCreateParams, - WorkspaceFolder, -) -from pygls.lsp.client import BaseLanguageClient + +from tests.integration.utils.client import CliceClient def pytest_addoption(parser: pytest.Parser) -> None: @@ -59,126 +37,6 @@ def pytest_addoption(parser: pytest.Parser) -> None: ) -class CliceClient(BaseLanguageClient): - """Language client that tracks server-sent notifications.""" - - def __init__(self) -> None: - 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.init_result: InitializeResult | None = None - - @self.feature(TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) - def on_diagnostics(params: PublishDiagnosticsParams) -> None: - raw_uri = params.uri - normalized = self._normalize_uri(raw_uri) - diags = list(params.diagnostics) - # Store under both raw and normalized forms. - self.diagnostics[raw_uri] = diags - if raw_uri != normalized: - self.diagnostics[normalized] = diags - for key in (raw_uri, normalized): - if key in self.diagnostics_events: - self.diagnostics_events[key].set() - - @self.feature(WINDOW_WORK_DONE_PROGRESS_CREATE) - def on_create_progress(params: WorkDoneProgressCreateParams) -> None: - 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) -> None: - token = str(params.token) if isinstance(params.token, int) else params.token - self.progress_events.append({"token": token, "value": params.value}) - - @staticmethod - def _normalize_uri(uri: str) -> str: - """Decode percent-encoded URIs so encoded and unencoded forms match.""" - return unquote(uri) - - def wait_for_diagnostics(self, uri: str) -> asyncio.Event: - """Get or create an event that fires when diagnostics arrive for uri.""" - uri = self._normalize_uri(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] - - async def initialize(self, workspace: Path) -> InitializeResult: - """Initialize the LSP server with a workspace folder and return the result.""" - result = await self.initialize_async( - InitializeParams( - capabilities=ClientCapabilities(), - root_uri=workspace.as_uri(), - workspace_folders=[ - WorkspaceFolder(uri=workspace.as_uri(), name="test") - ], - ) - ) - self.initialized(InitializedParams()) - self.init_result = result - return result - - def open(self, filepath: Path, version: int = 0) -> tuple[str, str]: - """Open a text document and return (normalized_uri, content). - - Sends the percent-encoded URI on the wire (RFC 3986), but returns - the normalized (decoded) form for internal lookups. - """ - content = filepath.read_bytes().decode("utf-8") - wire_uri = filepath.as_uri() - self.text_document_did_open( - DidOpenTextDocumentParams( - text_document=TextDocumentItem( - uri=wire_uri, language_id="cpp", version=version, text=content - ) - ) - ) - return self._normalize_uri(wire_uri), content - - def path_to_uri(self, filepath: Path) -> str: - """Convert a file path to a normalized URI without opening it.""" - return self._normalize_uri(filepath.as_uri()) - - async def wait_diagnostics(self, uri: str, timeout: float = 30.0) -> None: - """Wait for diagnostics on the given URI.""" - uri = self._normalize_uri(uri) - if uri in self.diagnostics: - return - event = self.wait_for_diagnostics(uri) - if uri in self.diagnostics: - return - await asyncio.wait_for(event.wait(), timeout=timeout) - - async def open_and_wait( - self, filepath: Path, timeout: float = 60.0 - ) -> tuple[str, str]: - """Open a file and trigger compilation by sending a hover request. - - With the pull-based compilation model, compilation is triggered - by feature requests (hover, completion, etc.) via ensure_compiled(), - not by didOpen. This method opens the file and sends a hover request - to trigger compilation, which publishes diagnostics as a side effect. - """ - uri, content = self.open(filepath) - event = self.wait_for_diagnostics(uri) - # Send hover to trigger pull-based compilation (ensure_compiled). - # This causes the server to compile the file and publish diagnostics. - await self.text_document_hover_async( - HoverParams( - text_document=TextDocumentIdentifier(uri=uri), - position=Position(line=0, character=0), - ) - ) - # Wait for diagnostics notification to be processed by the client. - await asyncio.wait_for(event.wait(), timeout=timeout) - return uri, content - - @pytest.fixture(scope="session") def executable(request: pytest.FixtureRequest) -> Path: exe = request.config.getoption("--executable") @@ -204,148 +62,10 @@ def executable(request: pytest.FixtureRequest) -> Path: def test_data_dir() -> Path: 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)) - - # Generate compile_commands.json for header_context (always regenerate - # because it contains absolute paths). - hc_dir = data_dir / "header_context" - hc_main = hc_dir / "main.cpp" - hc_cdb = hc_dir / "compile_commands.json" - if hc_main.exists(): - cdb = [ - { - "directory": hc_dir.as_posix(), - "file": hc_main.as_posix(), - "arguments": [ - "clang++", - "-std=c++17", - f"-I{hc_dir.as_posix()}", - "-fsyntax-only", - hc_main.as_posix(), - ], - } - ] - hc_cdb.write_text(json.dumps(cdb, indent=2)) - - # Generate compile_commands.json for multi_context (same file, two configs) - mc_dir = data_dir / "multi_context" - mc_main = mc_dir / "main.cpp" - mc_cdb = mc_dir / "compile_commands.json" - if mc_main.exists(): - cdb = [ - { - "directory": mc_dir.as_posix(), - "file": mc_main.as_posix(), - "arguments": [ - "clang++", - "-std=c++17", - "-DCONFIG_A", - "-fsyntax-only", - mc_main.as_posix(), - ], - }, - { - "directory": mc_dir.as_posix(), - "file": mc_main.as_posix(), - "arguments": [ - "clang++", - "-std=c++17", - "-DCONFIG_B", - "-fsyntax-only", - mc_main.as_posix(), - ], - }, - ] - mc_cdb.write_text(json.dumps(cdb, indent=2)) - - # Generate compile_commands.json for include_completion - ic_dir = data_dir / "include_completion" - ic_main = ic_dir / "main.cpp" - ic_cdb = ic_dir / "compile_commands.json" - if ic_main.exists() and not ic_cdb.exists(): - cdb = [ - { - "directory": ic_dir.as_posix(), - "file": ic_main.as_posix(), - "arguments": [ - "clang++", - "-std=c++17", - "-I.", - "-fsyntax-only", - ic_main.as_posix(), - ], - } - ] - ic_cdb.write_text(json.dumps(cdb, indent=2)) - - # Generate compile_commands.json for pch_test (always regenerate for - # absolute paths). - pt_dir = data_dir / "pch_test" - pt_cdb = pt_dir / "compile_commands.json" - for src_name in ["main.cpp", "no_includes.cpp"]: - src = pt_dir / src_name - if not src.exists(): - continue - if src_name == "main.cpp": - entries = [] - entries.append( - { - "directory": pt_dir.as_posix(), - "file": src.as_posix(), - "arguments": [ - "clang++", - "-std=c++17", - "-fsyntax-only", - src.as_posix(), - ], - } - ) - if pt_dir.exists(): - pt_cdb.write_text(json.dumps(entries, indent=2)) - + _generate_test_data_cdbs(data_dir) return data_dir -def generate_cdb(workspace: Path) -> None: - """Generate compile_commands.json using CMake with Ninja backend.""" - cmake = shutil.which("cmake") - if cmake is None: - raise RuntimeError("cmake executable not found in PATH") - toolchain = Path(__file__).resolve().parent.parent / "cmake" / "toolchain.cmake" - cmd = [ - cmake, - "-G", - "Ninja", - "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", - f"-DCMAKE_TOOLCHAIN_FILE={toolchain}", - "-S", - str(workspace), - "-B", - str(workspace / "build"), - ] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) - if result.returncode != 0: - raise RuntimeError(f"cmake failed:\n{result.stderr}") - - @pytest.fixture def workspace(request: pytest.FixtureRequest, test_data_dir: Path) -> Path | None: """Resolve workspace path from @pytest.mark.workspace("subdir") marker. @@ -393,7 +113,41 @@ async def client( yield c - # Graceful shutdown + await _shutdown_client(c) + + +def generate_cdb(workspace: Path) -> None: + """Generate compile_commands.json using CMake with Ninja backend.""" + cmake = shutil.which("cmake") + if cmake is None: + raise RuntimeError("cmake executable not found in PATH") + toolchain = Path(__file__).resolve().parent.parent / "cmake" / "toolchain.cmake" + cmd = [ + cmake, + "-G", + "Ninja", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + f"-DCMAKE_TOOLCHAIN_FILE={toolchain}", + "-S", + str(workspace), + "-B", + str(workspace / "build"), + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if result.returncode != 0: + raise RuntimeError(f"cmake failed:\n{result.stderr}") + + +async def make_client(executable: Path, workspace: Path) -> CliceClient: + """Spawn a fresh clice server and initialize it. For multi-session tests.""" + c = CliceClient() + await c.start_io(str(executable), "--mode", "pipe") + await c.initialize(workspace) + return c + + +async def _shutdown_client(c: CliceClient) -> None: + """Gracefully shut down a client, force-kill if needed.""" try: await asyncio.wait_for(c.shutdown_async(None), timeout=3.0) except Exception: @@ -403,12 +157,10 @@ async def client( 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() - # Dump server stderr warnings for diagnostics. try: server = getattr(c, "_server", None) if server and server.stderr: @@ -420,7 +172,6 @@ async def client( except Exception: pass - # Stop pygls client (with timeout to avoid hanging) try: c._stop_event.set() for task in c._async_tasks: @@ -428,3 +179,65 @@ async def client( await asyncio.sleep(0.1) except Exception: pass + + +shutdown_client = _shutdown_client # Public alias for multi-session tests + + +def _generate_test_data_cdbs(data_dir: Path) -> None: + """Generate compile_commands.json for all static test data directories.""" + + def _write(directory: Path, entries: list[dict]) -> None: + (directory / "compile_commands.json").write_text(json.dumps(entries, indent=2)) + + def _entry(directory: Path, source: Path, extra_args: list[str] | None = None): + args = ["clang++", "-std=c++17", "-fsyntax-only"] + if extra_args: + args.extend(extra_args) + args.append(source.as_posix()) + return { + "directory": directory.as_posix(), + "file": source.as_posix(), + "arguments": args, + } + + # hello_world + hw_dir = data_dir / "hello_world" + hw_main = hw_dir / "main.cpp" + if hw_main.exists(): + _write(hw_dir, [_entry(hw_dir, hw_main)]) + + # header_context (always regenerate — absolute paths) + hc_dir = data_dir / "header_context" + hc_main = hc_dir / "main.cpp" + if hc_main.exists(): + _write(hc_dir, [_entry(hc_dir, hc_main, [f"-I{hc_dir.as_posix()}"])]) + + # multi_context (same file, two configs) + mc_dir = data_dir / "multi_context" + mc_main = mc_dir / "main.cpp" + if mc_main.exists(): + _write( + mc_dir, + [ + _entry(mc_dir, mc_main, ["-DCONFIG_A"]), + _entry(mc_dir, mc_main, ["-DCONFIG_B"]), + ], + ) + + # include_completion + ic_dir = data_dir / "include_completion" + ic_main = ic_dir / "main.cpp" + if ic_main.exists(): + _write(ic_dir, [_entry(ic_dir, ic_main, ["-I."])]) + + # pch_test + pt_dir = data_dir / "pch_test" + if pt_dir.exists(): + entries = [] + for src_name in ["main.cpp", "no_includes.cpp"]: + src = pt_dir / src_name + if src.exists(): + entries.append(_entry(pt_dir, src)) + if entries: + _write(pt_dir, entries) diff --git a/tests/integration/compilation/__init__.py b/tests/integration/compilation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/test_pch.py b/tests/integration/compilation/test_pch.py similarity index 58% rename from tests/integration/test_pch.py rename to tests/integration/compilation/test_pch.py index 2d8ab875f..3b22788b6 100644 --- a/tests/integration/test_pch.py +++ b/tests/integration/compilation/test_pch.py @@ -5,18 +5,15 @@ import pytest from lsprotocol.types import ( CompletionParams, - DidChangeTextDocumentParams, DidCloseTextDocumentParams, HoverParams, Position, - TextDocumentContentChangeWholeDocument, - TextDocumentIdentifier, - VersionedTextDocumentIdentifier, ) - -def _doc(uri: str) -> TextDocumentIdentifier: - return TextDocumentIdentifier(uri=uri) +from tests.integration.utils import doc +from tests.integration.utils.workspace import did_change +from tests.integration.utils.wait import wait_for_recompile +from tests.integration.utils.assertions import assert_clean_compile, assert_no_errors @pytest.mark.workspace("pch_test") @@ -25,9 +22,8 @@ async def test_pch_diagnostics_on_open(client, workspace): uri, _ = await client.open_and_wait(workspace / "main.cpp") assert uri in client.diagnostics # main.cpp is well-formed, so diagnostics list should be empty (no errors). - diags = client.diagnostics[uri] - assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + assert_clean_compile(client, uri) + client.text_document_did_close(DidCloseTextDocumentParams(text_document=doc(uri))) @pytest.mark.workspace("pch_test") @@ -37,20 +33,11 @@ async def test_pch_body_edit_triggers_recompile(client, workspace): # Edit only the function body — preamble (#include "common.h") unchanged. new_content = content.replace("return result;", "return result + 1;") - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=1), - content_changes=[TextDocumentContentChangeWholeDocument(text=new_content)], - ) - ) + did_change(client, uri, 1, new_content) # Send hover to trigger recompilation via pull-based model. - event = client.wait_for_diagnostics(uri) - await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=0, character=0)) - ) - await asyncio.wait_for(event.wait(), timeout=30.0) + await wait_for_recompile(client, uri, timeout=30.0) assert uri in client.diagnostics - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.text_document_did_close(DidCloseTextDocumentParams(text_document=doc(uri))) @pytest.mark.workspace("pch_test") @@ -58,9 +45,8 @@ async def test_no_pch_for_no_includes(client, workspace): """A file with no #include directives should compile without PCH.""" uri, _ = await client.open_and_wait(workspace / "no_includes.cpp") assert uri in client.diagnostics - diags = client.diagnostics[uri] - assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + assert_clean_compile(client, uri) + client.text_document_did_close(DidCloseTextDocumentParams(text_document=doc(uri))) @pytest.mark.workspace("pch_test") @@ -70,10 +56,10 @@ async def test_hover_on_local_symbol(client, workspace): # Hover over "add" on line 2 (0-indexed): "int add(int a, int b) {" result = await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=2, character=4)) + HoverParams(text_document=doc(uri), position=Position(line=2, character=4)) ) assert result is not None - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.text_document_did_close(DidCloseTextDocumentParams(text_document=doc(uri))) @pytest.mark.workspace("pch_test") @@ -86,23 +72,18 @@ async def test_completion_with_pch(client, workspace): lines = new_content.split("\n") last_line = len(lines) - 1 - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=1), - content_changes=[TextDocumentContentChangeWholeDocument(text=new_content)], - ) - ) + did_change(client, uri, 1, new_content) # The completion request itself triggers compilation via ensure_compiled(). result = await client.text_document_completion_async( CompletionParams( - text_document=_doc(uri), + text_document=doc(uri), position=Position(line=last_line, character=3), ) ) # Completion should return results. assert result is not None - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.text_document_did_close(DidCloseTextDocumentParams(text_document=doc(uri))) @pytest.mark.workspace("pch_test") @@ -111,8 +92,7 @@ async def test_preamble_edit_then_hover(client, workspace): uri, content = await client.open_and_wait(workspace / "main.cpp") # Verify initial state is clean. - diags = client.diagnostics.get(uri, []) - assert len(diags) == 0, f"Expected no initial diagnostics, got: {diags}" + assert_clean_compile(client, uri) # Edit the preamble: add a second #include (triggers PCH rebuild). # Use project-local header instead of system header () to avoid @@ -120,31 +100,20 @@ async def test_preamble_edit_then_hover(client, workspace): new_content = '#include "common.h"\n#include "common.h"\n' + "\n".join( content.split("\n")[1:] ) - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=1), - content_changes=[TextDocumentContentChangeWholeDocument(text=new_content)], - ) - ) + did_change(client, uri, 1, new_content) # Trigger recompilation via hover — this will rebuild PCH with new preamble. - event = client.wait_for_diagnostics(uri) - await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=3, character=4)) - ) - await asyncio.wait_for(event.wait(), timeout=60.0) + await wait_for_recompile(client, uri) # AST should still be valid — no errors. - diags = client.diagnostics.get(uri, []) - errors = [d for d in diags if d.severity == 1] - assert len(errors) == 0, f"Expected no errors after preamble edit, got: {errors}" + assert_no_errors(client, uri, "Expected no errors after preamble edit") # Hover should still work on a symbol. result = await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=3, character=4)) + HoverParams(text_document=doc(uri), position=Position(line=3, character=4)) ) assert result is not None, "Hover failed after preamble edit" - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.text_document_did_close(DidCloseTextDocumentParams(text_document=doc(uri))) @pytest.mark.workspace("pch_test") @@ -160,25 +129,10 @@ async def test_preamble_edit_multiple_times(client, workspace): new_content = includes + "\n".join(content.split("\n")[1:]) version = i + 1 - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=version), - content_changes=[ - TextDocumentContentChangeWholeDocument(text=new_content) - ], - ) - ) + did_change(client, uri, version, new_content) - event = client.wait_for_diagnostics(uri) - await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=0, character=0)) - ) - await asyncio.wait_for(event.wait(), timeout=60.0) + await wait_for_recompile(client, uri) # After multiple edits, should still be clean. - diags = client.diagnostics.get(uri, []) - errors = [d for d in diags if d.severity == 1] - assert len(errors) == 0, ( - f"Expected no errors after multiple preamble edits, got: {errors}" - ) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + assert_no_errors(client, uri, "Expected no errors after multiple preamble edits") + client.text_document_did_close(DidCloseTextDocumentParams(text_document=doc(uri))) diff --git a/tests/integration/test_persistent_cache.py b/tests/integration/compilation/test_persistent_cache.py similarity index 67% rename from tests/integration/test_persistent_cache.py rename to tests/integration/compilation/test_persistent_cache.py index 5c93bdba3..a450cae03 100644 --- a/tests/integration/test_persistent_cache.py +++ b/tests/integration/compilation/test_persistent_cache.py @@ -6,99 +6,22 @@ """ import asyncio -import json -from pathlib import Path import pytest from lsprotocol.types import ( DidCloseTextDocumentParams, HoverParams, Position, - TextDocumentIdentifier, ) -from tests.conftest import CliceClient - - -def _write_cdb(workspace, files, extra_args=None): - """Write a compile_commands.json for the given source files.""" - entries = [] - for f in files: - args = ["clang++", "-std=c++17", "-fsyntax-only"] - if extra_args: - args.extend(extra_args) - args.append(str(workspace / f)) - entries.append( - { - "directory": str(workspace), - "file": str(workspace / f), - "arguments": args, - } - ) - (workspace / "compile_commands.json").write_text(json.dumps(entries, indent=2)) - - -def _doc(uri: str) -> TextDocumentIdentifier: - return TextDocumentIdentifier(uri=uri) - - -def _list_pch_files(workspace: Path) -> list[Path]: - """Return all .pch files in the cache directory.""" - pch_dir = workspace / ".clice" / "cache" / "pch" - if not pch_dir.exists(): - return [] - return sorted(pch_dir.glob("*.pch")) - - -def _list_pcm_files(workspace: Path) -> list[Path]: - """Return all .pcm files in the cache directory.""" - pcm_dir = workspace / ".clice" / "cache" / "pcm" - if not pcm_dir.exists(): - return [] - return sorted(pcm_dir.glob("*.pcm")) - - -def _cache_json(workspace: Path) -> dict | None: - """Read and parse cache.json, or return None if absent.""" - path = workspace / ".clice" / "cache" / "cache.json" - if not path.exists(): - return None - return json.loads(path.read_text()) - - -async def _make_client(executable: Path, workspace: Path) -> CliceClient: - """Spawn a fresh clice server and initialize it with the given workspace.""" - c = CliceClient() - await c.start_io(str(executable), "--mode", "pipe") - await c.initialize(workspace) - return c - - -async def _shutdown_client(c: CliceClient) -> None: - """Gracefully shut down a client.""" - try: - await asyncio.wait_for(c.shutdown_async(None), timeout=5.0) - except Exception: - pass - try: - c.exit(None) - except Exception: - pass - await asyncio.sleep(0.3) - if hasattr(c, "_server") and c._server is not None and c._server.returncode is None: - c._server.kill() - try: - c._stop_event.set() - for task in c._async_tasks: - task.cancel() - await asyncio.sleep(0.1) - except Exception: - pass - - -# ========================================================================= -# PCH persistent cache tests -# ========================================================================= +from tests.conftest import make_client, shutdown_client +from tests.integration.utils import write_cdb, doc +from tests.integration.utils.cache import ( + list_pch_files, + list_pcm_files, + read_cache_json, +) +from tests.integration.utils.assertions import assert_clean_compile async def test_pch_written_to_cache_dir(client, tmp_path): @@ -108,15 +31,14 @@ async def test_pch_written_to_cache_dir(client, tmp_path): (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { Foo f; return f.x; }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - diags = client.diagnostics.get(uri, []) - assert len(diags) == 0, f"Expected clean compile, got: {diags}" + assert_clean_compile(client, uri) # Verify PCH file exists in the cache directory. - pch_files = _list_pch_files(tmp_path) + pch_files = list_pch_files(tmp_path) assert len(pch_files) >= 1, "Expected at least one .pch file in .clice/cache/pch/" # Filename should be a 16-char hex hash + .pch assert pch_files[0].stem and len(pch_files[0].stem) == 16, ( @@ -130,13 +52,13 @@ async def test_cache_json_persisted(client, tmp_path): (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { return global_val; }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - assert len(client.diagnostics.get(uri, [])) == 0 + assert_clean_compile(client, uri) - cache = _cache_json(tmp_path) + cache = read_cache_json(tmp_path) assert cache is not None, "cache.json should exist after PCH build" assert "pch" in cache, "cache.json should have 'pch' section" assert len(cache["pch"]) >= 1, "Expected at least one PCH entry in cache.json" @@ -156,18 +78,18 @@ async def test_pch_reused_on_close_reopen(client, tmp_path): (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { Bar b; return b.y; }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) # First open — builds PCH. uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - assert len(client.diagnostics.get(uri, [])) == 0 + assert_clean_compile(client, uri) - pch_after_first = _list_pch_files(tmp_path) + pch_after_first = list_pch_files(tmp_path) assert len(pch_after_first) >= 1 # Close. - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.text_document_did_close(DidCloseTextDocumentParams(text_document=doc(uri))) await asyncio.sleep(0.5) # Clear diagnostics so we can wait for fresh ones. @@ -175,9 +97,9 @@ async def test_pch_reused_on_close_reopen(client, tmp_path): # Reopen — should reuse cached PCH. uri2, _ = await client.open_and_wait(tmp_path / "main.cpp") - assert len(client.diagnostics.get(uri2, [])) == 0 + assert_clean_compile(client, uri2) - pch_after_reopen = _list_pch_files(tmp_path) + pch_after_reopen = list_pch_files(tmp_path) assert pch_after_first == pch_after_reopen, ( "PCH file set should be identical after close+reopen" ) @@ -190,30 +112,30 @@ async def test_pch_survives_server_restart(executable, tmp_path): (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { Baz b; return b.z; }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) # Session 1: build PCH. - c1 = await _make_client(executable, tmp_path) + c1 = await make_client(executable, tmp_path) uri, _ = await c1.open_and_wait(tmp_path / "main.cpp") - assert len(c1.diagnostics.get(uri, [])) == 0 + assert_clean_compile(c1, uri) - pch_files_s1 = _list_pch_files(tmp_path) + pch_files_s1 = list_pch_files(tmp_path) assert len(pch_files_s1) >= 1, "PCH should be created in session 1" pch_mtime_s1 = pch_files_s1[0].stat().st_mtime - cache_s1 = _cache_json(tmp_path) + cache_s1 = read_cache_json(tmp_path) assert cache_s1 is not None, "cache.json should exist after session 1" - await _shutdown_client(c1) + await shutdown_client(c1) # Session 2: restart server, reopen file. - c2 = await _make_client(executable, tmp_path) + c2 = await make_client(executable, tmp_path) # Clear so we can detect fresh diagnostics. uri2, _ = await c2.open_and_wait(tmp_path / "main.cpp") - assert len(c2.diagnostics.get(uri2, [])) == 0 + assert_clean_compile(c2, uri2) # The same PCH file should still exist, not overwritten. - pch_files_s2 = _list_pch_files(tmp_path) + pch_files_s2 = list_pch_files(tmp_path) assert len(pch_files_s2) == len(pch_files_s1), ( "No new PCH files should be created in session 2" ) @@ -222,7 +144,7 @@ async def test_pch_survives_server_restart(executable, tmp_path): "PCH file should not be rebuilt (mtime should be unchanged)" ) - await _shutdown_client(c2) + await shutdown_client(c2) async def test_shared_preamble_shares_pch(client, tmp_path): @@ -235,17 +157,17 @@ async def test_shared_preamble_shares_pch(client, tmp_path): (tmp_path / "b.cpp").write_text( '#include "header.h"\nint fb() { return shared_val + 1; }\n' ) - _write_cdb(tmp_path, ["a.cpp", "b.cpp"]) + write_cdb(tmp_path, ["a.cpp", "b.cpp"]) await client.initialize(tmp_path) uri_a, _ = await client.open_and_wait(tmp_path / "a.cpp") uri_b, _ = await client.open_and_wait(tmp_path / "b.cpp") - assert len(client.diagnostics.get(uri_a, [])) == 0 - assert len(client.diagnostics.get(uri_b, [])) == 0 + assert_clean_compile(client, uri_a) + assert_clean_compile(client, uri_b) # Both files have the same preamble (#include "header.h"). # Content-addressed naming means only ONE .pch file should exist. - pch_files = _list_pch_files(tmp_path) + pch_files = list_pch_files(tmp_path) assert len(pch_files) == 1, ( f"Expected exactly 1 PCH file for shared preamble, got {len(pch_files)}: " f"{[f.name for f in pch_files]}" @@ -258,16 +180,16 @@ async def test_different_preamble_different_pch(client, tmp_path): (tmp_path / "b.h").write_text("#pragma once\nint val_b = 2;\n") (tmp_path / "a.cpp").write_text('#include "a.h"\nint fa() { return val_a; }\n') (tmp_path / "b.cpp").write_text('#include "b.h"\nint fb() { return val_b; }\n') - _write_cdb(tmp_path, ["a.cpp", "b.cpp"]) + write_cdb(tmp_path, ["a.cpp", "b.cpp"]) await client.initialize(tmp_path) uri_a, _ = await client.open_and_wait(tmp_path / "a.cpp") uri_b, _ = await client.open_and_wait(tmp_path / "b.cpp") - assert len(client.diagnostics.get(uri_a, [])) == 0 - assert len(client.diagnostics.get(uri_b, [])) == 0 + assert_clean_compile(client, uri_a) + assert_clean_compile(client, uri_b) # Different preambles → different hash → two separate .pch files. - pch_files = _list_pch_files(tmp_path) + pch_files = list_pch_files(tmp_path) assert len(pch_files) == 2, ( f"Expected 2 PCH files for different preambles, got {len(pch_files)}: " f"{[f.name for f in pch_files]}" @@ -281,13 +203,13 @@ async def test_pch_rebuilt_on_header_change(client, tmp_path): (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { V1 v; return v.a; }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - assert len(client.diagnostics.get(uri, [])) == 0 + assert_clean_compile(client, uri) - pch_before = _list_pch_files(tmp_path) + pch_before = list_pch_files(tmp_path) assert len(pch_before) >= 1 # Modify header — changes preamble content hash. @@ -299,14 +221,14 @@ async def test_pch_rebuilt_on_header_change(client, tmp_path): ) # Close and reopen to get fresh preamble. - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.text_document_did_close(DidCloseTextDocumentParams(text_document=doc(uri))) await asyncio.sleep(0.5) client.diagnostics.pop(uri, None) uri2, _ = await client.open_and_wait(tmp_path / "main.cpp") - assert len(client.diagnostics.get(uri2, [])) == 0 + assert_clean_compile(client, uri2) - pch_after = _list_pch_files(tmp_path) + pch_after = list_pch_files(tmp_path) # The preamble content changed (#include "header.h" is the same text, # but the preamble hash is computed from the preamble TEXT in the source file, # not from the header content). Since the #include line is identical, @@ -322,11 +244,11 @@ async def test_no_tmp_files_after_build(client, tmp_path): (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { return val; }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - assert len(client.diagnostics.get(uri, [])) == 0 + assert_clean_compile(client, uri) # No .tmp files should linger. pch_dir = tmp_path / ".clice" / "cache" / "pch" @@ -344,13 +266,13 @@ async def test_cache_dirs_created_on_startup(client, tmp_path): """The .clice/cache/pch/ and .clice/cache/pcm/ directories should be created when the server initializes a workspace.""" (tmp_path / "main.cpp").write_text("int main() { return 0; }\n") - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) # Trigger a compilation to ensure load_workspace() has completed # (it runs asynchronously after initialization). uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - assert len(client.diagnostics.get(uri, [])) == 0 + assert_clean_compile(client, uri) assert (tmp_path / ".clice" / "cache" / "pch").is_dir(), ( ".clice/cache/pch/ should be created" diff --git a/tests/integration/test_staleness.py b/tests/integration/compilation/test_staleness.py similarity index 65% rename from tests/integration/test_staleness.py rename to tests/integration/compilation/test_staleness.py index 47ce61384..88df093b3 100644 --- a/tests/integration/test_staleness.py +++ b/tests/integration/compilation/test_staleness.py @@ -6,8 +6,6 @@ """ import asyncio -import json -import os import shutil import pytest @@ -22,32 +20,9 @@ VersionedTextDocumentIdentifier, ) - -def _write_cdb(workspace, files, extra_args=None): - """Write a compile_commands.json for the given source files.""" - entries = [] - for f in files: - args = ["clang++", "-std=c++17", "-fsyntax-only"] - if extra_args: - args.extend(extra_args) - args.append(str(workspace / f)) - entries.append( - { - "directory": str(workspace), - "file": str(workspace / f), - "arguments": args, - } - ) - (workspace / "compile_commands.json").write_text(json.dumps(entries, indent=2)) - - -def _doc(uri: str) -> TextDocumentIdentifier: - return TextDocumentIdentifier(uri=uri) - - -# ========================================================================= -# Staleness detection tests -# ========================================================================= +from tests.integration.utils import write_cdb, doc +from tests.integration.utils.wait import wait_for_recompile +from tests.integration.utils.assertions import assert_clean_compile, assert_has_errors async def test_header_change_invalidates_ast(client, tmp_path): @@ -58,13 +33,12 @@ async def test_header_change_invalidates_ast(client, tmp_path): (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { return value(); }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) # First compile — should succeed with no diagnostics. uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - diags = client.diagnostics.get(uri, []) - assert len(diags) == 0, f"Expected clean compile, got: {diags}" + assert_clean_compile(client, uri) # Modify header on disk — introduce an error. # Ensure mtime advances past filesystem granularity (1s on some FSes). @@ -76,15 +50,10 @@ async def test_header_change_invalidates_ast(client, tmp_path): # Send another hover — ensure_compiled should detect mtime change # in deps and trigger recompilation. The recompilation publishes # fresh diagnostics as a side effect. - event = client.wait_for_diagnostics(uri) - await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=0, character=0)) - ) - await asyncio.wait_for(event.wait(), timeout=60.0) + await wait_for_recompile(client, uri) # Should now have diagnostics from the broken header. - diags = client.diagnostics.get(uri, []) - assert len(diags) > 0, "Expected diagnostics after header change" + assert_has_errors(client, uri, "Expected diagnostics after header change") async def test_header_change_invalidates_pch(client, tmp_path): @@ -93,13 +62,12 @@ async def test_header_change_invalidates_pch(client, tmp_path): (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { Foo f; return f.x; }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) # First compile — success. uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - diags = client.diagnostics.get(uri, []) - assert len(diags) == 0 + assert_clean_compile(client, uri) # Modify header — rename struct field. # Ensure mtime advances past filesystem granularity (1s on some FSes). @@ -110,30 +78,24 @@ async def test_header_change_invalidates_pch(client, tmp_path): # Hover again — PCH should rebuild, AST should recompile. # main.cpp uses f.x which no longer exists → diagnostics expected. - event = client.wait_for_diagnostics(uri) - await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=0, character=0)) - ) - await asyncio.wait_for(event.wait(), timeout=30.0) + await wait_for_recompile(client, uri, timeout=30.0) - diags = client.diagnostics.get(uri, []) - assert len(diags) > 0, "Expected error after header field rename" + assert_has_errors(client, uri, "Expected error after header field rename") async def test_no_change_skips_recompile(client, tmp_path): """When no dependency has changed, ensure_compiled should fast-path.""" (tmp_path / "main.cpp").write_text("int main() { return 0; }\n") - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - diags = client.diagnostics.get(uri, []) - assert len(diags) == 0 + assert_clean_compile(client, uri) # Second hover — should use cached AST (no recompilation). # Verify it returns quickly and doesn't crash. hover = await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=0, character=4)) + HoverParams(text_document=doc(uri), position=Position(line=0, character=4)) ) # "main" should be hoverable. assert hover is not None @@ -146,12 +108,11 @@ async def test_touch_without_content_change_skips_recompile(client, tmp_path): (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { return value(); }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - diags = client.diagnostics.get(uri, []) - assert len(diags) == 0 + assert_clean_compile(client, uri) # Touch the header — mtime changes but content stays the same. await asyncio.sleep(1.1) @@ -162,13 +123,12 @@ async def test_touch_without_content_change_skips_recompile(client, tmp_path): # Layer 2 hash confirms nothing actually changed → cached AST reused. # Hover on "main" (line 1, col 4) which should be hoverable. hover = await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=1, character=4)) + HoverParams(text_document=doc(uri), position=Position(line=1, character=4)) ) assert hover is not None # No new diagnostics should appear — the file is still clean. - diags = client.diagnostics.get(uri, []) - assert len(diags) == 0 + assert_clean_compile(client, uri) async def test_header_replaced_with_different_content(client, tmp_path): @@ -178,12 +138,11 @@ async def test_header_replaced_with_different_content(client, tmp_path): (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { return value(); }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - diags = client.diagnostics.get(uri, []) - assert len(diags) == 0 + assert_clean_compile(client, uri) # Replace header — delete and recreate with a breaking change. await asyncio.sleep(1.1) @@ -191,14 +150,9 @@ async def test_header_replaced_with_different_content(client, tmp_path): (tmp_path / "header.h").write_text("inline int renamed_value() { return 1; }\n") # main.cpp still calls value() which no longer exists → error. - event = client.wait_for_diagnostics(uri) - await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=0, character=0)) - ) - await asyncio.wait_for(event.wait(), timeout=60.0) + await wait_for_recompile(client, uri) - diags = client.diagnostics.get(uri, []) - assert len(diags) > 0, "Expected diagnostics after header replacement" + assert_has_errors(client, uri, "Expected diagnostics after header replacement") async def test_fix_error_clears_diagnostics(client, tmp_path): @@ -208,27 +162,21 @@ async def test_fix_error_clears_diagnostics(client, tmp_path): (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { return value(); }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) # First compile — should produce diagnostics. uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - diags = client.diagnostics.get(uri, []) - assert len(diags) > 0, "Expected diagnostics from broken header" + assert_has_errors(client, uri, "Expected diagnostics from broken header") # Fix the header. await asyncio.sleep(1.1) (tmp_path / "header.h").write_text("inline int value() { return 1; }\n") # Hover triggers recompilation — diagnostics should clear. - event = client.wait_for_diagnostics(uri) - await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=0, character=0)) - ) - await asyncio.wait_for(event.wait(), timeout=60.0) + await wait_for_recompile(client, uri) - diags = client.diagnostics.get(uri, []) - assert len(diags) == 0, f"Expected clean compile after fix, got: {diags}" + assert_clean_compile(client, uri) async def test_multiple_files_share_header(client, tmp_path): @@ -241,32 +189,24 @@ async def test_multiple_files_share_header(client, tmp_path): (tmp_path / "b.cpp").write_text( '#include "shared.h"\nint fb() { return shared(); }\n' ) - _write_cdb(tmp_path, ["a.cpp", "b.cpp"]) + write_cdb(tmp_path, ["a.cpp", "b.cpp"]) await client.initialize(tmp_path) uri_a, _ = await client.open_and_wait(tmp_path / "a.cpp") uri_b, _ = await client.open_and_wait(tmp_path / "b.cpp") - assert len(client.diagnostics.get(uri_a, [])) == 0 - assert len(client.diagnostics.get(uri_b, [])) == 0 + assert_clean_compile(client, uri_a) + assert_clean_compile(client, uri_b) # Break the shared header. await asyncio.sleep(1.1) (tmp_path / "shared.h").write_text("inline int shared() { return }\n") # Both files should get diagnostics after hover. - event_a = client.wait_for_diagnostics(uri_a) - await client.text_document_hover_async( - HoverParams(text_document=_doc(uri_a), position=Position(line=0, character=0)) - ) - await asyncio.wait_for(event_a.wait(), timeout=60.0) - assert len(client.diagnostics.get(uri_a, [])) > 0, "File A should have diagnostics" + await wait_for_recompile(client, uri_a) + assert_has_errors(client, uri_a, "File A should have diagnostics") - event_b = client.wait_for_diagnostics(uri_b) - await client.text_document_hover_async( - HoverParams(text_document=_doc(uri_b), position=Position(line=0, character=0)) - ) - await asyncio.wait_for(event_b.wait(), timeout=60.0) - assert len(client.diagnostics.get(uri_b, [])) > 0, "File B should have diagnostics" + await wait_for_recompile(client, uri_b) + assert_has_errors(client, uri_b, "File B should have diagnostics") async def test_transitive_header_change(client, tmp_path): @@ -276,40 +216,30 @@ async def test_transitive_header_change(client, tmp_path): (tmp_path / "main.cpp").write_text( '#include "mid.h"\nint main() { return base(); }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - assert len(client.diagnostics.get(uri, [])) == 0 + assert_clean_compile(client, uri) # Modify the transitive dep (base.h). await asyncio.sleep(1.1) (tmp_path / "base.h").write_text("inline int base() { return }\n") # broken - event = client.wait_for_diagnostics(uri) - await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=0, character=0)) - ) - await asyncio.wait_for(event.wait(), timeout=60.0) - - diags = client.diagnostics.get(uri, []) - assert len(diags) > 0, "Expected diagnostics from transitive header change" + await wait_for_recompile(client, uri) - -# ========================================================================= -# didChange / didOpen / didSave / didClose lifecycle tests -# ========================================================================= + assert_has_errors(client, uri, "Expected diagnostics from transitive header change") async def test_didchange_body_edit_recompiles(client, tmp_path): """Editing the body (not preamble) via didChange should trigger recompilation and update diagnostics.""" (tmp_path / "main.cpp").write_text("int main() { return 0; }\n") - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - assert len(client.diagnostics.get(uri, [])) == 0 + assert_clean_compile(client, uri) # Introduce a body error via didChange. event = client.wait_for_diagnostics(uri) @@ -324,12 +254,11 @@ async def test_didchange_body_edit_recompiles(client, tmp_path): ) ) await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=0, character=4)) + HoverParams(text_document=doc(uri), position=Position(line=0, character=4)) ) await asyncio.wait_for(event.wait(), timeout=30.0) - diags = client.diagnostics.get(uri, []) - assert len(diags) > 0, "Expected diagnostics after body error" + assert_has_errors(client, uri, "Expected diagnostics after body error") async def test_didchange_preamble_edit_recompiles(client, tmp_path): @@ -340,11 +269,11 @@ async def test_didchange_preamble_edit_recompiles(client, tmp_path): (tmp_path / "main.cpp").write_text( '#include "a.h"\nint main() { return from_a(); }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - assert len(client.diagnostics.get(uri, [])) == 0 + assert_clean_compile(client, uri) # Switch from a.h to b.h and call from_b() instead. event = client.wait_for_diagnostics(uri) @@ -359,29 +288,26 @@ async def test_didchange_preamble_edit_recompiles(client, tmp_path): ) ) await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=1, character=4)) + HoverParams(text_document=doc(uri), position=Position(line=1, character=4)) ) await asyncio.wait_for(event.wait(), timeout=30.0) # Should compile cleanly — from_b() is available via b.h. - diags = client.diagnostics.get(uri, []) - assert len(diags) == 0, ( - f"Expected clean compile after preamble switch, got: {diags}" - ) + assert_clean_compile(client, uri) async def test_didclose_then_reopen(client, tmp_path): """Closing and reopening a file should work correctly — the server should not retain stale state from the previous session.""" (tmp_path / "main.cpp").write_text("int main() { return 0; }\n") - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - assert len(client.diagnostics.get(uri, [])) == 0 + assert_clean_compile(client, uri) # Close the file. - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.text_document_did_close(DidCloseTextDocumentParams(text_document=doc(uri))) # Modify on disk while closed. await asyncio.sleep(1.1) @@ -389,22 +315,23 @@ async def test_didclose_then_reopen(client, tmp_path): # Reopen — should compile the new (broken) content from disk. uri2, _ = await client.open_and_wait(tmp_path / "main.cpp") - diags = client.diagnostics.get(uri2, []) - assert len(diags) > 0, "Expected diagnostics after reopen with broken content" + assert_has_errors( + client, uri2, "Expected diagnostics after reopen with broken content" + ) async def test_didclose_clears_hover(client, tmp_path): """After didClose, hover on the closed file should return None.""" (tmp_path / "main.cpp").write_text("int main() { return 0; }\n") - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.text_document_did_close(DidCloseTextDocumentParams(text_document=doc(uri))) hover = await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=0, character=4)) + HoverParams(text_document=doc(uri), position=Position(line=0, character=4)) ) assert hover is None, "Hover on closed file should return None" @@ -415,11 +342,11 @@ async def test_didsave_triggers_recompile_for_dependents(client, tmp_path): (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { return value(); }\n' ) - _write_cdb(tmp_path, ["main.cpp"]) + write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) uri, _ = await client.open_and_wait(tmp_path / "main.cpp") - assert len(client.diagnostics.get(uri, [])) == 0 + assert_clean_compile(client, uri) # Modify header on disk and send didSave. await asyncio.sleep(1.1) @@ -431,14 +358,11 @@ async def test_didsave_triggers_recompile_for_dependents(client, tmp_path): ) # Hover should detect the change and recompile. - event = client.wait_for_diagnostics(uri) - await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=0, character=0)) - ) - await asyncio.wait_for(event.wait(), timeout=60.0) + await wait_for_recompile(client, uri) - diags = client.diagnostics.get(uri, []) - assert len(diags) > 0, "Expected diagnostics after didSave on broken header" + assert_has_errors( + client, uri, "Expected diagnostics after didSave on broken header" + ) async def test_didsave_with_module_deps(client, test_data_dir, tmp_path): @@ -455,8 +379,7 @@ async def test_didsave_with_module_deps(client, test_data_dir, tmp_path): # Open and compile Mid (which imports Leaf). mid_uri, _ = await client.open_and_wait(tmp_path / "mid.cppm") - diags = client.diagnostics.get(mid_uri, []) - assert len(diags) == 0 + assert_clean_compile(client, mid_uri) # Modify Leaf on disk and send didSave — should invalidate Mid's deps. new_leaf = "export module Leaf;\nexport int leaf() { return 999; }\n" @@ -470,11 +393,6 @@ async def test_didsave_with_module_deps(client, test_data_dir, tmp_path): ) # Hover on Mid should trigger recompilation (Leaf PCM was invalidated). - event = client.wait_for_diagnostics(mid_uri) - await client.text_document_hover_async( - HoverParams(text_document=_doc(mid_uri), position=Position(line=0, character=0)) - ) - await asyncio.wait_for(event.wait(), timeout=60.0) + await wait_for_recompile(client, mid_uri) - diags = client.diagnostics.get(mid_uri, []) - assert len(diags) == 0, f"Expected clean compile after module update, got: {diags}" + assert_clean_compile(client, mid_uri) diff --git a/tests/integration/extensions/__init__.py b/tests/integration/extensions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/test_header_context.py b/tests/integration/extensions/test_header_context.py similarity index 77% rename from tests/integration/test_header_context.py rename to tests/integration/extensions/test_header_context.py index 416827b82..7430369bd 100644 --- a/tests/integration/test_header_context.py +++ b/tests/integration/extensions/test_header_context.py @@ -17,9 +17,7 @@ TextDocumentIdentifier, ) - -def _doc(uri: str) -> TextDocumentIdentifier: - return TextDocumentIdentifier(uri=uri) +from tests.integration.utils import doc def _get(obj, key, default=None): @@ -37,10 +35,7 @@ async def test_query_context_returns_host_sources(client, workspace): utils_h = workspace / "utils.h" utils_uri, _ = client.open(utils_h) - result = await asyncio.wait_for( - client.protocol.send_request_async("clice/queryContext", {"uri": utils_uri}), - timeout=30.0, - ) + result = await client.query_context(utils_uri) assert result is not None total = _get(result, "total") contexts = _get(result, "contexts", []) @@ -57,10 +52,7 @@ async def test_query_context_source_file_returns_cdb_entries(client, workspace): """clice/queryContext on a source file should return its CDB entries.""" main_uri, _ = await client.open_and_wait(workspace / "main.cpp") - result = await asyncio.wait_for( - client.protocol.send_request_async("clice/queryContext", {"uri": main_uri}), - timeout=30.0, - ) + result = await client.query_context(main_uri) assert result is not None # header_context workspace has exactly 1 CDB entry for main.cpp. assert _get(result, "total") == 1 @@ -76,10 +68,7 @@ async def test_current_context_default_null(client, workspace): utils_h = workspace / "utils.h" utils_uri, _ = client.open(utils_h) - result = await asyncio.wait_for( - client.protocol.send_request_async("clice/currentContext", {"uri": utils_uri}), - timeout=30.0, - ) + result = await client.current_context(utils_uri) assert result is not None assert _get(result, "context") is None, ( "Default context should be null (no explicit override)" @@ -95,21 +84,12 @@ async def test_switch_context_and_current_context(client, workspace): utils_uri, _ = client.open(utils_h) # Switch context to main.cpp. - switch_result = await asyncio.wait_for( - client.protocol.send_request_async( - "clice/switchContext", - {"uri": utils_uri, "contextUri": main_uri}, - ), - timeout=30.0, - ) + switch_result = await client.switch_context(utils_uri, main_uri) assert switch_result is not None assert _get(switch_result, "success") is True # Verify currentContext now returns main.cpp. - current = await asyncio.wait_for( - client.protocol.send_request_async("clice/currentContext", {"uri": utils_uri}), - timeout=30.0, - ) + current = await client.current_context(utils_uri) assert current is not None ctx = _get(current, "context") assert ctx is not None, ( @@ -129,37 +109,22 @@ async def test_full_context_flow(client, workspace): utils_uri, _ = client.open(utils_h) # 3. queryContext on utils.h -> should return main.cpp as a context option. - query = await asyncio.wait_for( - client.protocol.send_request_async("clice/queryContext", {"uri": utils_uri}), - timeout=30.0, - ) + query = await client.query_context(utils_uri) assert _get(query, "total") >= 1 contexts = _get(query, "contexts", []) context_uris = [_get(c, "uri") for c in contexts] assert any("main.cpp" in u for u in context_uris) # 4. currentContext on utils.h -> should be null (default). - current = await asyncio.wait_for( - client.protocol.send_request_async("clice/currentContext", {"uri": utils_uri}), - timeout=30.0, - ) + current = await client.current_context(utils_uri) assert _get(current, "context") is None # 5. switchContext on utils.h to main.cpp. - switch = await asyncio.wait_for( - client.protocol.send_request_async( - "clice/switchContext", - {"uri": utils_uri, "contextUri": main_uri}, - ), - timeout=30.0, - ) + switch = await client.switch_context(utils_uri, main_uri) assert _get(switch, "success") is True # 6. currentContext on utils.h -> should now be main.cpp. - current2 = await asyncio.wait_for( - client.protocol.send_request_async("clice/currentContext", {"uri": utils_uri}), - timeout=30.0, - ) + current2 = await client.current_context(utils_uri) ctx = _get(current2, "context") assert ctx is not None assert "main.cpp" in _get(ctx, "uri") @@ -169,7 +134,7 @@ async def test_full_context_flow(client, workspace): hover = await asyncio.wait_for( client.text_document_hover_async( HoverParams( - text_document=_doc(utils_uri), + text_document=doc(utils_uri), position=Position(line=6, character=12), # 'calc' function ) ), @@ -198,10 +163,7 @@ async def test_deep_nested_header_context(client, workspace): inner_uri, _ = client.open(inner_h) # queryContext on inner.h should find main.cpp through the chain. - result = await asyncio.wait_for( - client.protocol.send_request_async("clice/queryContext", {"uri": inner_uri}), - timeout=30.0, - ) + result = await client.query_context(inner_uri) assert result is not None total = _get(result, "total") assert total >= 1, f"Deep nested header should find host sources, got total={total}" @@ -221,20 +183,14 @@ async def test_deep_nested_switch_context_and_hover(client, workspace): inner_uri, _ = client.open(inner_h) # Switch inner.h context to main.cpp. - switch = await asyncio.wait_for( - client.protocol.send_request_async( - "clice/switchContext", - {"uri": inner_uri, "contextUri": main_uri}, - ), - timeout=30.0, - ) + switch = await client.switch_context(inner_uri, main_uri) assert _get(switch, "success") is True # Hover on 'inner_origin' in inner.h should work (Point available via preamble). hover = await asyncio.wait_for( client.text_document_hover_async( HoverParams( - text_document=_doc(inner_uri), + text_document=doc(inner_uri), position=Position(line=3, character=14), # 'inner_origin' ) ), @@ -249,10 +205,7 @@ async def test_query_context_multiple_cdb_entries(client, workspace): main_cpp = workspace / "main.cpp" main_uri, _ = await client.open_and_wait(main_cpp) - result = await asyncio.wait_for( - client.protocol.send_request_async("clice/queryContext", {"uri": main_uri}), - timeout=30.0, - ) + result = await client.query_context(main_uri) assert result is not None total = _get(result, "total") assert total >= 2, f"Should find at least 2 CDB entries, got total={total}" diff --git a/tests/integration/features/__init__.py b/tests/integration/features/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/features/test_completion.py b/tests/integration/features/test_completion.py new file mode 100644 index 000000000..44574f941 --- /dev/null +++ b/tests/integration/features/test_completion.py @@ -0,0 +1,191 @@ +"""Integration tests for #include completion and import completion in clice.""" + +import asyncio + +import pytest +from lsprotocol.types import ( + HoverParams, + Position, + TextDocumentIdentifier, +) + +from tests.integration.utils import doc +from tests.integration.utils.workspace import did_change + + +@pytest.mark.workspace("include_completion") +async def test_include_completion_quoted(client, workspace): + """Completion after #include " should list local headers.""" + uri, _ = await client.open_and_wait(workspace / "main.cpp") + + # Update content to trigger include completion for "my" prefix. + did_change(client, uri, 1, '#include "my') + + result = await client.completion_at(uri, 0, 12) # After "my" + + assert result is not None + items = result.items if hasattr(result, "items") else result + labels = [item.label for item in items] + assert "myheader.h" in labels + + client.close(uri) + + +@pytest.mark.workspace("include_completion") +async def test_include_completion_subdirectory(client, workspace): + """Completion for #include "subdir/ should list files in subdir.""" + uri, _ = await client.open_and_wait(workspace / "main.cpp") + + did_change(client, uri, 1, '#include "subdir/') + + result = await client.completion_at(uri, 0, 17) # After "subdir/" + + assert result is not None + items = result.items if hasattr(result, "items") else result + labels = [item.label for item in items] + assert "nested.h" in labels + + client.close(uri) + + +@pytest.mark.workspace("include_completion") +async def test_include_completion_angle_bracket(client, workspace): + """Completion after #include < should list system headers.""" + uri, _ = await client.open_and_wait(workspace / "main.cpp") + + did_change(client, uri, 1, "#include 0, f"Expected cstd* headers, got: {labels}" + + client.close(uri) + + +@pytest.mark.workspace("include_completion") +async def test_no_include_completion_on_regular_code(client, workspace): + """Regular code should NOT trigger include completion (goes to worker).""" + uri, _ = await client.open_and_wait(workspace / "main.cpp") + + did_change(client, uri, 1, "int x = ") + + result = await client.completion_at(uri, 0, 8) + + # Should return results from clang (keywords, etc.), not include paths. + # Verify none of the results look like header filenames. + assert result is not None + items = result.items if hasattr(result, "items") else result + labels = [item.label for item in items] + assert "myheader.h" not in labels + assert "nested.h" not in labels + + client.close(uri) + + +@pytest.mark.workspace("include_completion") +async def test_include_completion_empty_prefix(client, workspace): + """Completion after #include " with no prefix should list all local headers.""" + uri, _ = await client.open_and_wait(workspace / "main.cpp") + + did_change(client, uri, 1, '#include "') + + result = await client.completion_at(uri, 0, 10) # Right after the quote + + assert result is not None + items = result.items if hasattr(result, "items") else result + labels = [item.label for item in items] + # With empty prefix, should list available headers including myheader.h + # and the subdir/ directory entry. + assert "myheader.h" in labels + + client.close(uri) + + +@pytest.mark.workspace("modules/chained_modules") +async def test_import_completion_basic(client, workspace): + """Import completion should list known modules.""" + # First open mod_a to ensure it's scanned and module A is registered. + await client.open_and_wait(workspace / "mod_a.cppm") + + # Open mod_b and change its content to an incomplete import line. + uri_b, _ = client.open(workspace / "mod_b.cppm") + did_change(client, uri_b, 1, "import ") + + result = await client.completion_at(uri_b, 0, 7) + + assert result is not None + items = result.items if hasattr(result, "items") else result + labels = [item.label for item in items] + assert "A" in labels, f"Expected 'A' in completion labels, got: {labels}" + + +@pytest.mark.workspace("modules/chained_modules") +async def test_import_completion_with_prefix(client, workspace): + """Import completion with prefix should filter to matching modules.""" + # Open mod_a to register module A. + await client.open_and_wait(workspace / "mod_a.cppm") + + # Open mod_b and type 'import A' (with prefix). + uri_b, _ = client.open(workspace / "mod_b.cppm") + did_change(client, uri_b, 1, "import A") + + result = await client.completion_at(uri_b, 0, 8) + + assert result is not None + items = result.items if hasattr(result, "items") else result + labels = [item.label for item in items] + assert "A" in labels, f"Expected 'A' in completion labels, got: {labels}" + + +@pytest.mark.workspace("modules/dotted_module_name") +async def test_import_completion_dotted_names(client, workspace): + """Import completion should return dotted module names like my.app and my.io.""" + # Open both module files to register them. + await client.open_and_wait(workspace / "io.cppm") + await client.open_and_wait(workspace / "app.cppm") + + # Change app.cppm to an incomplete import with dotted prefix. + uri_app, _ = client.open(workspace / "app.cppm") + did_change(client, uri_app, 1, "import my.") + + result = await client.completion_at(uri_app, 0, 10) + + assert result is not None + items = result.items if hasattr(result, "items") else result + labels = [item.label for item in items] + assert "my.app" in labels or "my.io" in labels, ( + f"Expected dotted module names in completion labels, got: {labels}" + ) + + +@pytest.mark.workspace("modules/consumer_imports_module") +async def test_buffer_aware_module_deps(client, workspace): + """Adding import in buffer (unsaved) should still build the needed PCM.""" + # Open the module file first so it gets scanned. + await client.open_and_wait(workspace / "math.cppm") + + # Open main.cpp with new content that imports Math (simulating unsaved edit). + uri, _ = client.open(workspace / "main.cpp") + did_change(client, uri, 1, "import Math;\nint x = add(1, 2);\n") + + # Trigger compilation via hover (pull-based model). + event = client.wait_for_diagnostics(uri) + await client.text_document_hover_async( + HoverParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=0, character=0), + ) + ) + + # Wait for diagnostics. + await asyncio.wait_for(event.wait(), timeout=60.0) + + diags = client.diagnostics.get(uri, []) + # Should have no errors if Math PCM was built successfully from buffer scan. + errors = [d for d in diags if d.severity == 1] + assert len(errors) == 0, f"Expected no errors, got: {errors}" diff --git a/tests/integration/test_index.py b/tests/integration/features/test_index.py similarity index 64% rename from tests/integration/test_index.py rename to tests/integration/features/test_index.py index 035391350..7686dfc70 100644 --- a/tests/integration/test_index.py +++ b/tests/integration/features/test_index.py @@ -1,69 +1,30 @@ """Integration tests for index-based LSP features: GoToDefinition, FindReferences, CallHierarchy, TypeHierarchy, and WorkspaceSymbol.""" -import asyncio - import pytest from lsprotocol.types import ( CallHierarchyIncomingCallsParams, CallHierarchyOutgoingCallsParams, CallHierarchyPrepareParams, - DefinitionParams, - DidCloseTextDocumentParams, Position, - ReferenceContext, - ReferenceParams, - TextDocumentIdentifier, TypeHierarchyPrepareParams, TypeHierarchySubtypesParams, TypeHierarchySupertypesParams, WorkspaceSymbolParams, ) - -def _doc(uri: str) -> TextDocumentIdentifier: - return TextDocumentIdentifier(uri=uri) - - -async def _wait_for_index(client, uri, timeout=30): - """Trigger compilation via a hover request, then poll workspace/symbol until - indexing is ready (symbols appear).""" - from lsprotocol.types import HoverParams - - # Send a hover request to trigger ensure_compiled() → compilation → indexing - await client.text_document_hover_async( - HoverParams( - text_document=_doc(uri), - position=Position(line=0, character=0), - ) - ) - - for _ in range(timeout): - result = await client.workspace_symbol_async(WorkspaceSymbolParams(query="add")) - if result and any(s.name == "add" for s in result): - return True - await asyncio.sleep(1) - return False - - -# --------------------------------------------------------------------------- -# GoToDefinition -# --------------------------------------------------------------------------- +from tests.integration.utils import doc +from tests.integration.utils.wait import wait_for_index @pytest.mark.workspace("index_features") async def test_goto_definition(client, workspace): """Test GoToDefinition navigates from a call site to the function definition.""" uri, _ = await client.open_and_wait(workspace / "main.cpp") - assert await _wait_for_index(client, uri), "Index not ready after 30s" + assert await wait_for_index(client, uri), "Index not ready after 30s" # 'add' call on line 24 (0-indexed), column 12 - result = await client.text_document_definition_async( - DefinitionParams( - text_document=_doc(uri), - position=Position(line=24, character=12), - ) - ) + result = await client.definition_at(uri, 24, 12) assert result is not None locs = result if isinstance(result, list) else [result] assert len(locs) > 0, f"GoToDefinition returned empty list, result={result}" @@ -73,28 +34,17 @@ async def test_goto_definition(client, workspace): f" {[(loc.uri, loc.range.start.line, loc.range.start.character) for loc in locs]}" ) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -# --------------------------------------------------------------------------- -# FindReferences -# --------------------------------------------------------------------------- + client.close(uri) @pytest.mark.workspace("index_features") async def test_find_references(client, workspace): """Test FindReferences returns all usages of global_var.""" uri, _ = await client.open_and_wait(workspace / "main.cpp") - assert await _wait_for_index(client, uri), "Index not ready after 30s" + assert await wait_for_index(client, uri), "Index not ready after 30s" # global_var definition on line 30 (0-indexed), column 4 - result = await client.text_document_references_async( - ReferenceParams( - text_document=_doc(uri), - position=Position(line=30, character=4), - context=ReferenceContext(include_declaration=True), - ) - ) + result = await client.references_at(uri, 30, 4, include_declaration=True) assert result is not None, "FindReferences returned None" # global_var is declared on line 30 and used on lines 33 and 37 assert len(result) >= 3, ( @@ -102,24 +52,19 @@ async def test_find_references(client, workspace): f" {[(r.uri, r.range.start.line) for r in result]}" ) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -# --------------------------------------------------------------------------- -# CallHierarchy -# --------------------------------------------------------------------------- + client.close(uri) @pytest.mark.workspace("index_features") async def test_call_hierarchy_prepare(client, workspace): """Test prepareCallHierarchy returns a CallHierarchyItem for 'add'.""" uri, _ = await client.open_and_wait(workspace / "main.cpp") - assert await _wait_for_index(client, uri), "Index not ready after 30s" + assert await wait_for_index(client, uri), "Index not ready after 30s" # 'add' definition at line 18 (0-indexed), column 4 result = await client.text_document_prepare_call_hierarchy_async( CallHierarchyPrepareParams( - text_document=_doc(uri), + text_document=doc(uri), position=Position(line=18, character=4), ) ) @@ -127,19 +72,19 @@ async def test_call_hierarchy_prepare(client, workspace): assert len(result) > 0, f"prepareCallHierarchy returned empty, result={result}" assert result[0].name == "add", f"Expected 'add', got '{result[0].name}'" - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.close(uri) @pytest.mark.workspace("index_features") async def test_call_hierarchy_incoming(client, workspace): """Test incomingCalls shows compute() calls add().""" uri, _ = await client.open_and_wait(workspace / "main.cpp") - assert await _wait_for_index(client, uri), "Index not ready after 30s" + assert await wait_for_index(client, uri), "Index not ready after 30s" # Prepare call hierarchy for 'add' at line 18 (0-indexed), column 4 items = await client.text_document_prepare_call_hierarchy_async( CallHierarchyPrepareParams( - text_document=_doc(uri), + text_document=doc(uri), position=Position(line=18, character=4), ) ) @@ -154,19 +99,19 @@ async def test_call_hierarchy_incoming(client, workspace): f"Expected 'compute' in callers, got {caller_names}" ) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.close(uri) @pytest.mark.workspace("index_features") async def test_call_hierarchy_outgoing(client, workspace): """Test outgoingCalls shows compute() calls add().""" uri, _ = await client.open_and_wait(workspace / "main.cpp") - assert await _wait_for_index(client, uri), "Index not ready after 30s" + assert await wait_for_index(client, uri), "Index not ready after 30s" # Prepare call hierarchy for 'compute' at line 23 (0-indexed), column 4 items = await client.text_document_prepare_call_hierarchy_async( CallHierarchyPrepareParams( - text_document=_doc(uri), + text_document=doc(uri), position=Position(line=23, character=4), ) ) @@ -179,24 +124,19 @@ async def test_call_hierarchy_outgoing(client, workspace): callee_names = [call.to.name for call in outgoing] assert "add" in callee_names, f"Expected 'add' in callees, got {callee_names}" - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -# --------------------------------------------------------------------------- -# TypeHierarchy -# --------------------------------------------------------------------------- + client.close(uri) @pytest.mark.workspace("index_features") async def test_type_hierarchy_prepare(client, workspace): """Test prepareTypeHierarchy returns a TypeHierarchyItem for 'Dog'.""" uri, _ = await client.open_and_wait(workspace / "main.cpp") - assert await _wait_for_index(client, uri), "Index not ready after 30s" + assert await wait_for_index(client, uri), "Index not ready after 30s" # 'Dog' at line 8 (0-indexed), column 7 result = await client.text_document_prepare_type_hierarchy_async( TypeHierarchyPrepareParams( - text_document=_doc(uri), + text_document=doc(uri), position=Position(line=8, character=7), ) ) @@ -204,19 +144,19 @@ async def test_type_hierarchy_prepare(client, workspace): assert len(result) > 0, f"prepareTypeHierarchy returned empty" assert result[0].name == "Dog", f"Expected 'Dog', got '{result[0].name}'" - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.close(uri) @pytest.mark.workspace("index_features") async def test_type_hierarchy_supertypes(client, workspace): """Test supertypes of Dog includes Animal.""" uri, _ = await client.open_and_wait(workspace / "main.cpp") - assert await _wait_for_index(client, uri), "Index not ready after 30s" + assert await wait_for_index(client, uri), "Index not ready after 30s" # 'Dog' at line 8 (0-indexed), column 7 items = await client.text_document_prepare_type_hierarchy_async( TypeHierarchyPrepareParams( - text_document=_doc(uri), + text_document=doc(uri), position=Position(line=8, character=7), ) ) @@ -231,19 +171,19 @@ async def test_type_hierarchy_supertypes(client, workspace): f"Expected 'Animal' in supertypes, got {supertype_names}" ) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.close(uri) @pytest.mark.workspace("index_features") async def test_type_hierarchy_subtypes(client, workspace): """Test subtypes of Animal includes Dog and Cat.""" uri, _ = await client.open_and_wait(workspace / "main.cpp") - assert await _wait_for_index(client, uri), "Index not ready after 30s" + assert await wait_for_index(client, uri), "Index not ready after 30s" # 'Animal' at line 1, column 7 items = await client.text_document_prepare_type_hierarchy_async( TypeHierarchyPrepareParams( - text_document=_doc(uri), + text_document=doc(uri), position=Position(line=1, character=7), ) ) @@ -257,37 +197,32 @@ async def test_type_hierarchy_subtypes(client, workspace): assert "Dog" in subtype_names, f"Expected 'Dog' in subtypes, got {subtype_names}" assert "Cat" in subtype_names, f"Expected 'Cat' in subtypes, got {subtype_names}" - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -# --------------------------------------------------------------------------- -# WorkspaceSymbol -# --------------------------------------------------------------------------- + client.close(uri) @pytest.mark.workspace("index_features") async def test_workspace_symbol(client, workspace): """Test workspace/symbol finds symbols by query string.""" uri, _ = await client.open_and_wait(workspace / "main.cpp") - assert await _wait_for_index(client, uri), "Index not ready after 30s" + assert await wait_for_index(client, uri), "Index not ready after 30s" result = await client.workspace_symbol_async(WorkspaceSymbolParams(query="add")) assert result is not None names = [s.name for s in result] assert "add" in names - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.close(uri) @pytest.mark.workspace("index_features") async def test_workspace_symbol_class(client, workspace): """Test workspace/symbol finds class symbols.""" uri, _ = await client.open_and_wait(workspace / "main.cpp") - assert await _wait_for_index(client, uri), "Index not ready after 30s" + assert await wait_for_index(client, uri), "Index not ready after 30s" result = await client.workspace_symbol_async(WorkspaceSymbolParams(query="Animal")) assert result is not None names = [s.name for s in result] assert "Animal" in names - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.close(uri) diff --git a/tests/integration/features/test_server.py b/tests/integration/features/test_server.py new file mode 100644 index 000000000..8b665710e --- /dev/null +++ b/tests/integration/features/test_server.py @@ -0,0 +1,249 @@ +"""Integration tests for the clice MasterServer using pygls.""" + +import asyncio + +import pytest +from lsprotocol.types import ( + DidSaveTextDocumentParams, + Position, + Range, +) + +from tests.integration.utils import doc +from tests.integration.utils.workspace import did_change + + +@pytest.mark.workspace("hello_world") +async def test_server_info(client, workspace): + assert client.init_result.server_info.name == "clice" + assert client.init_result.server_info.version == "0.1.0" + + +@pytest.mark.workspace("hello_world") +async def test_capabilities(client, workspace): + def capability_enabled(capability: object) -> bool: + return capability is True or ( + capability is not None and capability is not False + ) + + caps = client.init_result.capabilities + assert caps.hover_provider is True + assert caps.completion_provider is not None + assert capability_enabled(caps.definition_provider) + assert capability_enabled(caps.document_symbol_provider) + assert capability_enabled(caps.folding_range_provider) + assert capability_enabled(caps.inlay_hint_provider) + assert capability_enabled(caps.code_action_provider) + assert caps.semantic_tokens_provider is not None + + +@pytest.mark.workspace("hello_world") +async def test_semantic_token_modifier_legend(client, workspace): + legend = client.init_result.capabilities.semantic_tokens_provider.legend + assert legend is not None + assert list(legend.token_modifiers) == [ + "declaration", + "definition", + "const", + "overloaded", + "typed", + "templated", + "deprecated", + "deduced", + "readonly", + "static", + "abstract", + "virtual", + "dependentName", + "defaultLibrary", + "usedAsMutableReference", + "usedAsMutablePointer", + "constructorOrDestructor", + "userDefined", + "functionScope", + "classScope", + "fileScope", + "globalScope", + ] + + +@pytest.mark.workspace("hello_world") +async def test_did_open_close_cycle(client, workspace): + uri, _ = client.open(workspace / "main.cpp") + await asyncio.sleep(0.5) + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_shutdown_exit(client, workspace): + await client.shutdown_async(None) + + +@pytest.mark.workspace("hello_world") +async def test_feature_requests_after_close(client, workspace): + uri, _ = client.open(workspace / "main.cpp") + client.close(uri) + result = await client.hover_at(uri, 0, 0) + assert result is None + + +@pytest.mark.workspace("hello_world") +async def test_incremental_change(client, workspace): + uri, content = client.open(workspace / "main.cpp") + for i in range(5): + content += f"\n// change {i}" + did_change(client, uri, i + 1, content) + await asyncio.sleep(0.05) + await asyncio.sleep(1) + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_diagnostics_received(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + assert uri in client.diagnostics + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_hover_before_compile(client, workspace): + uri, _ = client.open(workspace / "main.cpp") + result = await client.hover_at(uri, 0, 0, timeout=90.0) + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_completion_request(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + result = await client.completion_at(uri, 0, 0) + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_signature_help_request(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + result = await client.signature_help_at(uri, 0, 0) + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_definition_request(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + result = await client.definition_at(uri, 2, 4) + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_document_symbol_request(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + result = await client.document_symbols(uri) + assert result is not None + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_folding_range_request(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + result = await client.folding_ranges(uri) + assert result is not None + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_semantic_tokens_request(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + result = await client.semantic_tokens_full(uri) + assert result is not None + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_inlay_hint_request(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + result = await client.inlay_hints( + uri, + Range(start=Position(line=0, character=0), end=Position(line=10, character=0)), + ) + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_code_action_request(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + result = await client.code_actions( + uri, + Range(start=Position(line=0, character=0), end=Position(line=0, character=10)), + ) + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_document_link_request(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + result = await client.document_links(uri) + assert result is not None + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_rapid_changes_stress(client, workspace): + uri, content = client.open(workspace / "main.cpp") + for i in range(20): + content += f"\n// stress change {i}\n" + did_change(client, uri, i + 1, content) + await asyncio.sleep(2) + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_save_notification(client, workspace): + uri, _ = client.open(workspace / "main.cpp") + await asyncio.sleep(0.5) + client.text_document_did_save(DidSaveTextDocumentParams(text_document=doc(uri))) + await asyncio.sleep(0.5) + client.close(uri) + + +@pytest.mark.workspace("hello_world") +async def test_hover_on_unknown_file(client, workspace): + result = await client.hover_at("file:///nonexistent/fake.cpp", 0, 0) + assert result is None + + +@pytest.mark.workspace("hello_world") +async def test_all_features_after_compile_wait(client, workspace): + """Exercise all feature requests after compilation completes.""" + uri, _ = await client.open_and_wait(workspace / "main.cpp") + + hover = await client.hover_at(uri, 2, 4) + assert hover is not None + + completion = await client.completion_at(uri, 7, 18) + + await client.signature_help_at(uri, 0, 0) + + await client.definition_at(uri, 2, 4) + + symbols = await client.document_symbols(uri) + assert symbols is not None + + folding = await client.folding_ranges(uri) + assert folding is not None + + tokens = await client.semantic_tokens_full(uri) + assert tokens is not None + + links = await client.document_links(uri) + assert links is not None + + await client.code_actions( + uri, + Range(start=Position(line=0, character=0), end=Position(line=0, character=10)), + ) + + await client.inlay_hints( + uri, + Range(start=Position(line=0, character=0), end=Position(line=10, character=0)), + ) + + client.close(uri) diff --git a/tests/integration/lifecycle/__init__.py b/tests/integration/lifecycle/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/test_file_operation.py b/tests/integration/lifecycle/test_file_operation.py similarity index 51% rename from tests/integration/test_file_operation.py rename to tests/integration/lifecycle/test_file_operation.py index 49343c85f..f324ac733 100644 --- a/tests/integration/test_file_operation.py +++ b/tests/integration/lifecycle/test_file_operation.py @@ -5,16 +5,16 @@ import pytest from lsprotocol.types import ( CompletionParams, - DidChangeTextDocumentParams, DidCloseTextDocumentParams, HoverParams, Position, SignatureHelpParams, - TextDocumentContentChangeWholeDocument, - TextDocumentIdentifier, VersionedTextDocumentIdentifier, ) +from tests.integration.utils import doc +from tests.integration.utils.workspace import did_change + @pytest.mark.workspace("hello_world") async def test_did_open(client, workspace): @@ -25,16 +25,10 @@ async def test_did_open(client, workspace): @pytest.mark.workspace("hello_world") async def test_did_change(client, workspace): uri, content = client.open(workspace / "main.cpp") - for i in range(20): content += "\n" await asyncio.sleep(0.2) - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=i + 1), - content_changes=[TextDocumentContentChangeWholeDocument(text=content)], - ) - ) + did_change(client, uri, i + 1, content) await asyncio.sleep(5) @@ -47,43 +41,22 @@ async def test_clang_tidy(client, workspace): @pytest.mark.workspace("hello_world") async def test_hover_save_close(client, workspace): main_cpp = workspace / "main.cpp" - uri, content = client.open(main_cpp) - - # Hover on 'add' — this triggers ensure_compiled() which compiles the file hover = await client.text_document_hover_async( - HoverParams( - text_document=TextDocumentIdentifier(uri=uri), - position=Position(line=2, character=4), - ) + HoverParams(text_document=doc(uri), position=Position(line=2, character=4)) ) assert hover is not None assert hover.contents is not None - - # Completion and signature help at (0,0) — just verify no crash await client.text_document_completion_async( - CompletionParams( - text_document=TextDocumentIdentifier(uri=uri), - position=Position(line=0, character=0), - ) + CompletionParams(text_document=doc(uri), position=Position(line=0, character=0)) ) await client.text_document_signature_help_async( SignatureHelpParams( - text_document=TextDocumentIdentifier(uri=uri), - position=Position(line=0, character=0), + text_document=doc(uri), position=Position(line=0, character=0) ) ) - - # Close - client.text_document_did_close( - DidCloseTextDocumentParams(text_document=TextDocumentIdentifier(uri=uri)) - ) - - # Hover on closed file should return null + client.text_document_did_close(DidCloseTextDocumentParams(text_document=doc(uri))) closed_hover = await client.text_document_hover_async( - HoverParams( - text_document=TextDocumentIdentifier(uri=uri), - position=Position(line=0, character=0), - ) + HoverParams(text_document=doc(uri), position=Position(line=0, character=0)) ) assert closed_hover is None diff --git a/tests/integration/test_lifecycle.py b/tests/integration/lifecycle/test_lifecycle.py similarity index 100% rename from tests/integration/test_lifecycle.py rename to tests/integration/lifecycle/test_lifecycle.py diff --git a/tests/integration/modules/__init__.py b/tests/integration/modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/test_modules.py b/tests/integration/modules/test_modules.py similarity index 98% rename from tests/integration/test_modules.py rename to tests/integration/modules/test_modules.py index d36d0aafd..b5690c512 100644 --- a/tests/integration/test_modules.py +++ b/tests/integration/modules/test_modules.py @@ -6,7 +6,6 @@ import pytest from tests.conftest import generate_cdb from lsprotocol.types import ( - DidCloseTextDocumentParams, DidOpenTextDocumentParams, HoverParams, Position, @@ -14,6 +13,8 @@ TextDocumentItem, ) +from tests.integration.utils.assertions import assert_clean_compile, assert_has_errors + @pytest.mark.workspace("modules/single_module_no_deps") async def test_single_module_no_deps(client, workspace): @@ -175,9 +176,7 @@ async def test_save_recompile(client, test_data_dir, tmp_path): ) # Close Leaf, modify on disk, and reopen with new content. - client.text_document_did_close( - DidCloseTextDocumentParams(text_document=TextDocumentIdentifier(uri=leaf_uri)) - ) + client.close(leaf_uri) new_content = "export module Leaf;\nexport int leaf() { return 100; }\n" (tmp_path / "leaf.cppm").write_text(new_content) diff --git a/tests/integration/stress/__init__.py b/tests/integration/stress/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/test_rapid_edit.py b/tests/integration/stress/test_rapid_edit.py similarity index 75% rename from tests/integration/test_rapid_edit.py rename to tests/integration/stress/test_rapid_edit.py index 0cd5acc36..70ac47625 100644 --- a/tests/integration/test_rapid_edit.py +++ b/tests/integration/stress/test_rapid_edit.py @@ -4,18 +4,13 @@ import pytest from lsprotocol.types import ( - DidChangeTextDocumentParams, DidCloseTextDocumentParams, HoverParams, Position, - TextDocumentContentChangeWholeDocument, - TextDocumentIdentifier, - VersionedTextDocumentIdentifier, ) - -def _doc(uri: str) -> TextDocumentIdentifier: - return TextDocumentIdentifier(uri=uri) +from tests.integration.utils import doc +from tests.integration.utils.workspace import did_change @pytest.mark.workspace("hello_world") @@ -32,7 +27,7 @@ async def test_rapid_edits_with_hover(client, workspace): hover = await asyncio.wait_for( client.text_document_hover_async( HoverParams( - text_document=_doc(uri), + text_document=doc(uri), position=Position(line=2, character=4), ) ), @@ -43,21 +38,14 @@ async def test_rapid_edits_with_hover(client, workspace): # 50 rapid body edits, each followed by a hover request. for i in range(50): new_content = content.replace("return a + b;", f"return a + b + {i};") - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=i + 2), - content_changes=[ - TextDocumentContentChangeWholeDocument(text=new_content) - ], - ) - ) + did_change(client, uri, i + 2, new_content) # Fire-and-forget hover on 'add' — just ensure it doesn't hang. # We don't await the result here to simulate real editor behavior # where requests overlap. asyncio.ensure_future( client.text_document_hover_async( HoverParams( - text_document=_doc(uri), + text_document=doc(uri), position=Position(line=2, character=4), ) ) @@ -71,7 +59,7 @@ async def test_rapid_edits_with_hover(client, workspace): final_hover = await asyncio.wait_for( client.text_document_hover_async( HoverParams( - text_document=_doc(uri), + text_document=doc(uri), position=Position(line=2, character=4), ) ), @@ -80,4 +68,4 @@ async def test_rapid_edits_with_hover(client, workspace): assert final_hover is not None, "Final hover returned None — worker may have hung" assert final_hover.contents is not None, "Final hover contents should not be None" - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + client.text_document_did_close(DidCloseTextDocumentParams(text_document=doc(uri))) diff --git a/tests/integration/test_import_completion.py b/tests/integration/test_import_completion.py deleted file mode 100644 index 5f3842533..000000000 --- a/tests/integration/test_import_completion.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Integration tests for import completion and buffer-aware module dependency features.""" - -import asyncio - -import pytest -from lsprotocol.types import ( - CompletionParams, - DidChangeTextDocumentParams, - HoverParams, - Position, - TextDocumentContentChangeWholeDocument, - TextDocumentIdentifier, - VersionedTextDocumentIdentifier, -) - - -def _doc(uri: str) -> TextDocumentIdentifier: - return TextDocumentIdentifier(uri=uri) - - -@pytest.mark.workspace("modules/chained_modules") -async def test_import_completion_basic(client, workspace): - """Import completion should list known modules.""" - # First open mod_a to ensure it's scanned and module A is registered. - await client.open_and_wait(workspace / "mod_a.cppm") - - # Open mod_b and change its content to an incomplete import line. - uri_b, _ = client.open(workspace / "mod_b.cppm") - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri_b, version=1), - content_changes=[TextDocumentContentChangeWholeDocument(text="import ")], - ) - ) - - result = await client.text_document_completion_async( - CompletionParams( - text_document=_doc(uri_b), - position=Position(line=0, character=7), - ) - ) - - assert result is not None - items = result.items if hasattr(result, "items") else result - labels = [item.label for item in items] - assert "A" in labels, f"Expected 'A' in completion labels, got: {labels}" - - -@pytest.mark.workspace("modules/chained_modules") -async def test_import_completion_with_prefix(client, workspace): - """Import completion with prefix should filter to matching modules.""" - # Open mod_a to register module A. - await client.open_and_wait(workspace / "mod_a.cppm") - - # Open mod_b and type 'import A' (with prefix). - uri_b, _ = client.open(workspace / "mod_b.cppm") - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri_b, version=1), - content_changes=[TextDocumentContentChangeWholeDocument(text="import A")], - ) - ) - - result = await client.text_document_completion_async( - CompletionParams( - text_document=_doc(uri_b), - position=Position(line=0, character=8), - ) - ) - - assert result is not None - items = result.items if hasattr(result, "items") else result - labels = [item.label for item in items] - assert "A" in labels, f"Expected 'A' in completion labels, got: {labels}" - - -@pytest.mark.workspace("modules/dotted_module_name") -async def test_import_completion_dotted_names(client, workspace): - """Import completion should return dotted module names like my.app and my.io.""" - # Open both module files to register them. - await client.open_and_wait(workspace / "io.cppm") - await client.open_and_wait(workspace / "app.cppm") - - # Change app.cppm to an incomplete import with dotted prefix. - uri_app, _ = client.open(workspace / "app.cppm") - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri_app, version=1), - content_changes=[TextDocumentContentChangeWholeDocument(text="import my.")], - ) - ) - - result = await client.text_document_completion_async( - CompletionParams( - text_document=_doc(uri_app), - position=Position(line=0, character=10), - ) - ) - - assert result is not None - items = result.items if hasattr(result, "items") else result - labels = [item.label for item in items] - assert "my.app" in labels or "my.io" in labels, ( - f"Expected dotted module names in completion labels, got: {labels}" - ) - - -@pytest.mark.workspace("modules/consumer_imports_module") -async def test_buffer_aware_module_deps(client, workspace): - """Adding import in buffer (unsaved) should still build the needed PCM.""" - # Open the module file first so it gets scanned. - await client.open_and_wait(workspace / "math.cppm") - - # Open main.cpp with new content that imports Math (simulating unsaved edit). - uri, _ = client.open(workspace / "main.cpp") - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=1), - content_changes=[ - TextDocumentContentChangeWholeDocument( - text="import Math;\nint x = add(1, 2);\n" - ) - ], - ) - ) - - # Trigger compilation via hover (pull-based model). - event = client.wait_for_diagnostics(uri) - await client.text_document_hover_async( - HoverParams( - text_document=_doc(uri), - position=Position(line=0, character=0), - ) - ) - - # Wait for diagnostics. - await asyncio.wait_for(event.wait(), timeout=60.0) - - diags = client.diagnostics.get(uri, []) - # Should have no errors if Math PCM was built successfully from buffer scan. - errors = [d for d in diags if d.severity == 1] - assert len(errors) == 0, f"Expected no errors, got: {errors}" diff --git a/tests/integration/test_include_completion.py b/tests/integration/test_include_completion.py deleted file mode 100644 index e984fb22c..000000000 --- a/tests/integration/test_include_completion.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Integration tests for #include completion in clice.""" - -import pytest -from lsprotocol.types import ( - CompletionParams, - DidChangeTextDocumentParams, - DidCloseTextDocumentParams, - Position, - TextDocumentContentChangeWholeDocument, - TextDocumentIdentifier, - VersionedTextDocumentIdentifier, -) - - -def _doc(uri: str) -> TextDocumentIdentifier: - return TextDocumentIdentifier(uri=uri) - - -@pytest.mark.workspace("include_completion") -async def test_include_completion_quoted(client, workspace): - """Completion after #include " should list local headers.""" - uri, _ = await client.open_and_wait(workspace / "main.cpp") - - # Update content to trigger include completion for "my" prefix. - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=1), - content_changes=[ - TextDocumentContentChangeWholeDocument(text='#include "my') - ], - ) - ) - - result = await client.text_document_completion_async( - CompletionParams( - text_document=_doc(uri), - position=Position(line=0, character=12), # After "my" - ) - ) - - assert result is not None - items = result.items if hasattr(result, "items") else result - labels = [item.label for item in items] - assert "myheader.h" in labels - - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("include_completion") -async def test_include_completion_subdirectory(client, workspace): - """Completion for #include "subdir/ should list files in subdir.""" - uri, _ = await client.open_and_wait(workspace / "main.cpp") - - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=1), - content_changes=[ - TextDocumentContentChangeWholeDocument(text='#include "subdir/') - ], - ) - ) - - result = await client.text_document_completion_async( - CompletionParams( - text_document=_doc(uri), - position=Position(line=0, character=17), # After "subdir/" - ) - ) - - assert result is not None - items = result.items if hasattr(result, "items") else result - labels = [item.label for item in items] - assert "nested.h" in labels - - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("include_completion") -async def test_include_completion_angle_bracket(client, workspace): - """Completion after #include < should list system headers.""" - uri, _ = await client.open_and_wait(workspace / "main.cpp") - - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=1), - content_changes=[ - TextDocumentContentChangeWholeDocument(text="#include 0, f"Expected cstd* headers, got: {labels}" - - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("include_completion") -async def test_no_include_completion_on_regular_code(client, workspace): - """Regular code should NOT trigger include completion (goes to worker).""" - uri, _ = await client.open_and_wait(workspace / "main.cpp") - - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=1), - content_changes=[TextDocumentContentChangeWholeDocument(text="int x = ")], - ) - ) - - result = await client.text_document_completion_async( - CompletionParams( - text_document=_doc(uri), - position=Position(line=0, character=8), - ) - ) - - # Should return results from clang (keywords, etc.), not include paths. - # Verify none of the results look like header filenames. - assert result is not None - items = result.items if hasattr(result, "items") else result - labels = [item.label for item in items] - assert "myheader.h" not in labels - assert "nested.h" not in labels - - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("include_completion") -async def test_include_completion_empty_prefix(client, workspace): - """Completion after #include " with no prefix should list all local headers.""" - uri, _ = await client.open_and_wait(workspace / "main.cpp") - - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=1), - content_changes=[TextDocumentContentChangeWholeDocument(text='#include "')], - ) - ) - - result = await client.text_document_completion_async( - CompletionParams( - text_document=_doc(uri), - position=Position(line=0, character=10), # Right after the quote - ) - ) - - assert result is not None - items = result.items if hasattr(result, "items") else result - labels = [item.label for item in items] - # With empty prefix, should list available headers including myheader.h - # and the subdir/ directory entry. - assert "myheader.h" in labels - - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) diff --git a/tests/integration/test_server.py b/tests/integration/test_server.py deleted file mode 100644 index 64aa994e9..000000000 --- a/tests/integration/test_server.py +++ /dev/null @@ -1,345 +0,0 @@ -"""Integration tests for the clice MasterServer using pygls.""" - -import asyncio - -import pytest -from lsprotocol.types import ( - CodeActionContext, - CodeActionParams, - CompletionParams, - DefinitionParams, - DidCloseTextDocumentParams, - DidChangeTextDocumentParams, - DidSaveTextDocumentParams, - DocumentLinkParams, - DocumentSymbolParams, - FoldingRangeParams, - HoverParams, - InlayHintParams, - Position, - Range, - SemanticTokensParams, - SignatureHelpParams, - TextDocumentContentChangeWholeDocument, - TextDocumentIdentifier, - VersionedTextDocumentIdentifier, -) - - -def _doc(uri: str) -> TextDocumentIdentifier: - return TextDocumentIdentifier(uri=uri) - - -@pytest.mark.workspace("hello_world") -async def test_server_info(client, workspace): - assert client.init_result.server_info.name == "clice" - assert client.init_result.server_info.version == "0.1.0" - - -@pytest.mark.workspace("hello_world") -async def test_capabilities(client, workspace): - def capability_enabled(capability: object) -> bool: - return capability is True or ( - capability is not None and capability is not False - ) - - caps = client.init_result.capabilities - assert caps.hover_provider is True - assert caps.completion_provider is not None - assert capability_enabled(caps.definition_provider) - assert capability_enabled(caps.document_symbol_provider) - assert capability_enabled(caps.folding_range_provider) - assert capability_enabled(caps.inlay_hint_provider) - assert capability_enabled(caps.code_action_provider) - assert caps.semantic_tokens_provider is not None - - -@pytest.mark.workspace("hello_world") -async def test_semantic_token_modifier_legend(client, workspace): - legend = client.init_result.capabilities.semantic_tokens_provider.legend - assert legend is not None - assert list(legend.token_modifiers) == [ - "declaration", - "definition", - "const", - "overloaded", - "typed", - "templated", - "deprecated", - "deduced", - "readonly", - "static", - "abstract", - "virtual", - "dependentName", - "defaultLibrary", - "usedAsMutableReference", - "usedAsMutablePointer", - "constructorOrDestructor", - "userDefined", - "functionScope", - "classScope", - "fileScope", - "globalScope", - ] - - -@pytest.mark.workspace("hello_world") -async def test_did_open_close_cycle(client, workspace): - uri, _ = client.open(workspace / "main.cpp") - await asyncio.sleep(0.5) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_shutdown_exit(client, workspace): - await client.shutdown_async(None) - - -@pytest.mark.workspace("hello_world") -async def test_feature_requests_after_close(client, workspace): - uri, _ = client.open(workspace / "main.cpp") - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - result = await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=0, character=0)) - ) - assert result is None - - -@pytest.mark.workspace("hello_world") -async def test_incremental_change(client, workspace): - uri, content = client.open(workspace / "main.cpp") - for i in range(5): - content += f"\n// change {i}" - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=i + 1), - content_changes=[TextDocumentContentChangeWholeDocument(text=content)], - ) - ) - await asyncio.sleep(0.05) - await asyncio.sleep(1) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_diagnostics_received(client, workspace): - uri, _ = await client.open_and_wait(workspace / "main.cpp") - assert uri in client.diagnostics - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_hover_before_compile(client, workspace): - uri, _ = client.open(workspace / "main.cpp") - result = await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=0, character=0)) - ) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_completion_request(client, workspace): - uri, _ = await client.open_and_wait(workspace / "main.cpp") - result = await client.text_document_completion_async( - CompletionParams( - text_document=_doc(uri), position=Position(line=0, character=0) - ) - ) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_signature_help_request(client, workspace): - uri, _ = await client.open_and_wait(workspace / "main.cpp") - result = await client.text_document_signature_help_async( - SignatureHelpParams( - text_document=_doc(uri), position=Position(line=0, character=0) - ) - ) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_definition_request(client, workspace): - uri, _ = await client.open_and_wait(workspace / "main.cpp") - result = await client.text_document_definition_async( - DefinitionParams( - text_document=_doc(uri), position=Position(line=2, character=4) - ) - ) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_document_symbol_request(client, workspace): - uri, _ = await client.open_and_wait(workspace / "main.cpp") - result = await client.text_document_document_symbol_async( - DocumentSymbolParams(text_document=_doc(uri)) - ) - assert result is not None - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_folding_range_request(client, workspace): - uri, _ = await client.open_and_wait(workspace / "main.cpp") - result = await client.text_document_folding_range_async( - FoldingRangeParams(text_document=_doc(uri)) - ) - assert result is not None - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_semantic_tokens_request(client, workspace): - uri, _ = await client.open_and_wait(workspace / "main.cpp") - result = await client.text_document_semantic_tokens_full_async( - SemanticTokensParams(text_document=_doc(uri)) - ) - assert result is not None - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_inlay_hint_request(client, workspace): - uri, _ = await client.open_and_wait(workspace / "main.cpp") - result = await client.text_document_inlay_hint_async( - InlayHintParams( - text_document=_doc(uri), - range=Range( - start=Position(line=0, character=0), end=Position(line=10, character=0) - ), - ) - ) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_code_action_request(client, workspace): - uri, _ = await client.open_and_wait(workspace / "main.cpp") - result = await client.text_document_code_action_async( - CodeActionParams( - text_document=_doc(uri), - range=Range( - start=Position(line=0, character=0), end=Position(line=0, character=10) - ), - context=CodeActionContext(diagnostics=[]), - ) - ) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_document_link_request(client, workspace): - uri, _ = await client.open_and_wait(workspace / "main.cpp") - result = await client.text_document_document_link_async( - DocumentLinkParams(text_document=_doc(uri)) - ) - assert result is not None - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_rapid_changes_stress(client, workspace): - uri, content = client.open(workspace / "main.cpp") - for i in range(20): - content += f"\n// stress change {i}\n" - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=i + 1), - content_changes=[TextDocumentContentChangeWholeDocument(text=content)], - ) - ) - await asyncio.sleep(2) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_save_notification(client, workspace): - uri, _ = client.open(workspace / "main.cpp") - await asyncio.sleep(0.5) - client.text_document_did_save(DidSaveTextDocumentParams(text_document=_doc(uri))) - await asyncio.sleep(0.5) - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) - - -@pytest.mark.workspace("hello_world") -async def test_hover_on_unknown_file(client, workspace): - result = await client.text_document_hover_async( - HoverParams( - text_document=_doc("file:///nonexistent/fake.cpp"), - position=Position(line=0, character=0), - ) - ) - assert result is None - - -@pytest.mark.workspace("hello_world") -async def test_all_features_after_compile_wait(client, workspace): - """Exercise all feature requests after compilation completes.""" - uri, _ = await client.open_and_wait(workspace / "main.cpp") - - hover = await client.text_document_hover_async( - HoverParams(text_document=_doc(uri), position=Position(line=2, character=4)) - ) - assert hover is not None - - completion = await client.text_document_completion_async( - CompletionParams( - text_document=_doc(uri), position=Position(line=7, character=18) - ) - ) - - await client.text_document_signature_help_async( - SignatureHelpParams( - text_document=_doc(uri), position=Position(line=0, character=0) - ) - ) - - await client.text_document_definition_async( - DefinitionParams( - text_document=_doc(uri), position=Position(line=2, character=4) - ) - ) - - symbols = await client.text_document_document_symbol_async( - DocumentSymbolParams(text_document=_doc(uri)) - ) - assert symbols is not None - - folding = await client.text_document_folding_range_async( - FoldingRangeParams(text_document=_doc(uri)) - ) - assert folding is not None - - tokens = await client.text_document_semantic_tokens_full_async( - SemanticTokensParams(text_document=_doc(uri)) - ) - assert tokens is not None - - links = await client.text_document_document_link_async( - DocumentLinkParams(text_document=_doc(uri)) - ) - assert links is not None - - await client.text_document_code_action_async( - CodeActionParams( - text_document=_doc(uri), - range=Range( - start=Position(line=0, character=0), end=Position(line=0, character=10) - ), - context=CodeActionContext(diagnostics=[]), - ) - ) - - await client.text_document_inlay_hint_async( - InlayHintParams( - text_document=_doc(uri), - range=Range( - start=Position(line=0, character=0), end=Position(line=10, character=0) - ), - ) - ) - - client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) diff --git a/tests/integration/utils/__init__.py b/tests/integration/utils/__init__.py new file mode 100644 index 000000000..541c90fec --- /dev/null +++ b/tests/integration/utils/__init__.py @@ -0,0 +1,32 @@ +"""Shared utilities for clice integration tests.""" + +from tests.integration.utils.client import CliceClient +from tests.integration.utils.workspace import doc, write_cdb, write_source +from tests.integration.utils.assertions import ( + assert_no_errors, + assert_has_errors, + assert_diagnostics_count, +) +from tests.integration.utils.wait import wait_for_recompile, wait_for_index +from tests.integration.utils.cache import ( + list_pch_files, + list_pcm_files, + list_tmp_files, + read_cache_json, +) + +__all__ = [ + "CliceClient", + "doc", + "write_cdb", + "write_source", + "assert_no_errors", + "assert_has_errors", + "assert_diagnostics_count", + "wait_for_recompile", + "wait_for_index", + "list_pch_files", + "list_pcm_files", + "list_tmp_files", + "read_cache_json", +] diff --git a/tests/integration/utils/assertions.py b/tests/integration/utils/assertions.py new file mode 100644 index 000000000..a2ed7b3c2 --- /dev/null +++ b/tests/integration/utils/assertions.py @@ -0,0 +1,50 @@ +"""Diagnostic assertion helpers for integration tests.""" + +from lsprotocol.types import Diagnostic, DiagnosticSeverity + + +def get_errors(diagnostics: list[Diagnostic]) -> list[Diagnostic]: + """Filter diagnostics to errors only (severity == 1).""" + return [d for d in diagnostics if d.severity == DiagnosticSeverity.Error] + + +def assert_no_errors(client, uri: str, msg: str = "") -> None: + """Assert that there are no error-level diagnostics for the given URI.""" + diags = client.diagnostics.get(uri, []) + errors = get_errors(diags) + if msg: + assert len(errors) == 0, f"{msg}: {errors}" + else: + assert len(errors) == 0, f"Expected no errors, got: {errors}" + + +def assert_has_errors(client, uri: str, msg: str = "") -> None: + """Assert that there is at least one error-level diagnostic for the given URI.""" + diags = client.diagnostics.get(uri, []) + errors = get_errors(diags) + if msg: + assert len(errors) > 0, msg + else: + assert len(errors) > 0, "Expected at least one error diagnostic" + + +def assert_diagnostics_count( + client, + uri: str, + count: int, + *, + severity: int | None = None, +) -> None: + """Assert exact number of diagnostics, optionally filtered by severity.""" + diags = client.diagnostics.get(uri, []) + if severity is not None: + diags = [d for d in diags if d.severity == severity] + assert len(diags) == count, ( + f"Expected {count} diagnostics (severity={severity}), got {len(diags)}: {diags}" + ) + + +def assert_clean_compile(client, uri: str) -> None: + """Assert the file compiled without any diagnostics at all.""" + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected clean compile, got: {diags}" diff --git a/tests/integration/utils/cache.py b/tests/integration/utils/cache.py new file mode 100644 index 000000000..d6769cfab --- /dev/null +++ b/tests/integration/utils/cache.py @@ -0,0 +1,38 @@ +"""Cache inspection helpers for persistent cache tests.""" + +import json +from pathlib import Path + + +def list_pch_files(workspace: Path) -> list[Path]: + """Return all .pch files in the cache directory, sorted.""" + pch_dir = workspace / ".clice" / "cache" / "pch" + if not pch_dir.exists(): + return [] + return sorted(pch_dir.glob("*.pch")) + + +def list_pcm_files(workspace: Path) -> list[Path]: + """Return all .pcm files in the cache directory, sorted.""" + pcm_dir = workspace / ".clice" / "cache" / "pcm" + if not pcm_dir.exists(): + return [] + return sorted(pcm_dir.glob("*.pcm")) + + +def read_cache_json(workspace: Path) -> dict | None: + """Read and parse cache.json, or return None if absent.""" + path = workspace / ".clice" / "cache" / "cache.json" + if not path.exists(): + return None + return json.loads(path.read_text()) + + +def list_tmp_files(workspace: Path) -> list[Path]: + """Return stale .tmp files in pch and pcm cache directories.""" + tmp_files = [] + for subdir in ("pch", "pcm"): + d = workspace / ".clice" / "cache" / subdir + if d.exists(): + tmp_files.extend(d.glob("*.tmp")) + return tmp_files diff --git a/tests/integration/utils/client.py b/tests/integration/utils/client.py new file mode 100644 index 000000000..009a3f2f5 --- /dev/null +++ b/tests/integration/utils/client.py @@ -0,0 +1,331 @@ +"""CliceClient — enhanced LSP client for integration testing.""" + +import asyncio +from pathlib import Path +from urllib.parse import unquote + +from lsprotocol.types import ( + PROGRESS, + TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS, + WINDOW_WORK_DONE_PROGRESS_CREATE, + ClientCapabilities, + CodeActionContext, + CodeActionParams, + CompletionParams, + DefinitionParams, + Diagnostic, + DidCloseTextDocumentParams, + DidOpenTextDocumentParams, + DocumentLinkParams, + DocumentSymbolParams, + FoldingRangeParams, + HoverParams, + InlayHintParams, + InitializeParams, + InitializeResult, + InitializedParams, + Position, + ProgressParams, + PublishDiagnosticsParams, + Range, + ReferenceContext, + ReferenceParams, + SemanticTokensParams, + SignatureHelpParams, + TextDocumentIdentifier, + TextDocumentItem, + WorkDoneProgressCreateParams, + WorkspaceFolder, +) +from pygls.lsp.client import BaseLanguageClient + + +class CliceClient(BaseLanguageClient): + """Language client that tracks server-sent notifications and provides + convenience methods for common LSP operations.""" + + def __init__(self) -> None: + 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.init_result: InitializeResult | None = None + + @self.feature(TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) + def on_diagnostics(params: PublishDiagnosticsParams) -> None: + raw_uri = params.uri + normalized = self._normalize_uri(raw_uri) + diags = list(params.diagnostics) + self.diagnostics[raw_uri] = diags + if raw_uri != normalized: + self.diagnostics[normalized] = diags + for key in (raw_uri, normalized): + if key in self.diagnostics_events: + self.diagnostics_events[key].set() + + @self.feature(WINDOW_WORK_DONE_PROGRESS_CREATE) + def on_create_progress(params: WorkDoneProgressCreateParams) -> None: + 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) -> None: + token = str(params.token) if isinstance(params.token, int) else params.token + self.progress_events.append({"token": token, "value": params.value}) + + # ── URI helpers ────────────────────────────────────────────────── + + @staticmethod + def _normalize_uri(uri: str) -> str: + return unquote(uri) + + def path_to_uri(self, filepath: Path) -> str: + return self._normalize_uri(filepath.as_uri()) + + # ── Lifecycle ──────────────────────────────────────────────────── + + async def initialize(self, workspace: Path) -> InitializeResult: + result = await self.initialize_async( + InitializeParams( + capabilities=ClientCapabilities(), + root_uri=workspace.as_uri(), + workspace_folders=[ + WorkspaceFolder(uri=workspace.as_uri(), name="test") + ], + ) + ) + self.initialized(InitializedParams()) + self.init_result = result + return result + + # ── Document operations ────────────────────────────────────────── + + def open(self, filepath: Path, version: int = 0) -> tuple[str, str]: + """Open a text document. Returns (normalized_uri, content).""" + content = filepath.read_bytes().decode("utf-8") + wire_uri = filepath.as_uri() + self.text_document_did_open( + DidOpenTextDocumentParams( + text_document=TextDocumentItem( + uri=wire_uri, language_id="cpp", version=version, text=content + ) + ) + ) + return self._normalize_uri(wire_uri), content + + def close(self, uri: str) -> None: + """Close a text document.""" + self.text_document_did_close( + DidCloseTextDocumentParams(text_document=TextDocumentIdentifier(uri=uri)) + ) + + # ── Diagnostics ────────────────────────────────────────────────── + + def wait_for_diagnostics(self, uri: str) -> asyncio.Event: + uri = self._normalize_uri(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] + + async def wait_diagnostics(self, uri: str, timeout: float = 30.0) -> None: + uri = self._normalize_uri(uri) + if uri in self.diagnostics: + return + event = self.wait_for_diagnostics(uri) + if uri in self.diagnostics: + return + await asyncio.wait_for(event.wait(), timeout=timeout) + + # ── Compile & wait ─────────────────────────────────────────────── + + async def open_and_wait( + self, filepath: Path, timeout: float = 60.0 + ) -> tuple[str, str]: + """Open a file and trigger compilation via hover. Waits for diagnostics.""" + uri, content = self.open(filepath) + event = self.wait_for_diagnostics(uri) + await self.text_document_hover_async( + HoverParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=0, character=0), + ) + ) + await asyncio.wait_for(event.wait(), timeout=timeout) + return uri, content + + # ── Feature request shortcuts ──────────────────────────────────── + + async def hover_at( + self, uri: str, line: int, character: int, *, timeout: float = 30.0 + ): + """Send hover request at given position.""" + return await asyncio.wait_for( + self.text_document_hover_async( + HoverParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=line, character=character), + ) + ), + timeout=timeout, + ) + + async def definition_at( + self, uri: str, line: int, character: int, *, timeout: float = 30.0 + ): + """Send go-to-definition request at given position.""" + return await asyncio.wait_for( + self.text_document_definition_async( + DefinitionParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=line, character=character), + ) + ), + timeout=timeout, + ) + + async def references_at( + self, + uri: str, + line: int, + character: int, + *, + include_declaration: bool = True, + timeout: float = 30.0, + ): + """Send find-references request at given position.""" + return await asyncio.wait_for( + self.text_document_references_async( + ReferenceParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=line, character=character), + context=ReferenceContext(include_declaration=include_declaration), + ) + ), + timeout=timeout, + ) + + async def completion_at( + self, uri: str, line: int, character: int, *, timeout: float = 30.0 + ): + """Send completion request at given position.""" + return await asyncio.wait_for( + self.text_document_completion_async( + CompletionParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=line, character=character), + ) + ), + timeout=timeout, + ) + + async def signature_help_at( + self, uri: str, line: int, character: int, *, timeout: float = 30.0 + ): + """Send signature help request at given position.""" + return await asyncio.wait_for( + self.text_document_signature_help_async( + SignatureHelpParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=line, character=character), + ) + ), + timeout=timeout, + ) + + async def document_symbols(self, uri: str, *, timeout: float = 30.0): + """Send document symbol request.""" + return await asyncio.wait_for( + self.text_document_document_symbol_async( + DocumentSymbolParams(text_document=TextDocumentIdentifier(uri=uri)) + ), + timeout=timeout, + ) + + async def folding_ranges(self, uri: str, *, timeout: float = 30.0): + """Send folding range request.""" + return await asyncio.wait_for( + self.text_document_folding_range_async( + FoldingRangeParams(text_document=TextDocumentIdentifier(uri=uri)) + ), + timeout=timeout, + ) + + async def semantic_tokens_full(self, uri: str, *, timeout: float = 30.0): + """Send semantic tokens (full) request.""" + return await asyncio.wait_for( + self.text_document_semantic_tokens_full_async( + SemanticTokensParams(text_document=TextDocumentIdentifier(uri=uri)) + ), + timeout=timeout, + ) + + async def inlay_hints(self, uri: str, range_: Range, *, timeout: float = 30.0): + """Send inlay hint request for given range.""" + return await asyncio.wait_for( + self.text_document_inlay_hint_async( + InlayHintParams( + text_document=TextDocumentIdentifier(uri=uri), range=range_ + ) + ), + timeout=timeout, + ) + + async def code_actions( + self, + uri: str, + range_: Range, + diagnostics=None, + *, + timeout: float = 30.0, + ): + """Send code action request.""" + return await asyncio.wait_for( + self.text_document_code_action_async( + CodeActionParams( + text_document=TextDocumentIdentifier(uri=uri), + range=range_, + context=CodeActionContext(diagnostics=diagnostics or []), + ) + ), + timeout=timeout, + ) + + async def document_links(self, uri: str, *, timeout: float = 30.0): + """Send document link request.""" + return await asyncio.wait_for( + self.text_document_document_link_async( + DocumentLinkParams(text_document=TextDocumentIdentifier(uri=uri)) + ), + timeout=timeout, + ) + + # ── Extension protocol ─────────────────────────────────────────── + + async def query_context(self, uri: str, *, timeout: float = 30.0): + """Send clice/queryContext extension request.""" + return await asyncio.wait_for( + self.protocol.send_request_async("clice/queryContext", {"uri": uri}), + timeout=timeout, + ) + + async def current_context(self, uri: str, *, timeout: float = 30.0): + """Send clice/currentContext extension request.""" + return await asyncio.wait_for( + self.protocol.send_request_async("clice/currentContext", {"uri": uri}), + timeout=timeout, + ) + + async def switch_context( + self, uri: str, context_uri: str, *, timeout: float = 30.0 + ): + """Send clice/switchContext extension request.""" + return await asyncio.wait_for( + self.protocol.send_request_async( + "clice/switchContext", {"uri": uri, "contextUri": context_uri} + ), + timeout=timeout, + ) diff --git a/tests/integration/utils/wait.py b/tests/integration/utils/wait.py new file mode 100644 index 000000000..91d4a26ba --- /dev/null +++ b/tests/integration/utils/wait.py @@ -0,0 +1,56 @@ +"""Wait and polling helpers for integration tests.""" + +import asyncio + +from lsprotocol.types import ( + HoverParams, + Position, + TextDocumentIdentifier, + WorkspaceSymbolParams, +) + + +async def wait_for_recompile(client, uri: str, *, timeout: float = 60.0) -> None: + """Trigger recompilation via hover and wait for fresh diagnostics. + + Useful after didChange or on-disk file modifications. Sends a hover + request at (0,0) to trigger ensure_compiled(), then waits for the + resulting diagnostics notification. + """ + event = client.wait_for_diagnostics(uri) + await client.text_document_hover_async( + HoverParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=0, character=0), + ) + ) + await asyncio.wait_for(event.wait(), timeout=timeout) + + +async def wait_for_index( + client, + uri: str, + symbol_name: str = "add", + *, + timeout: int = 30, +) -> bool: + """Poll workspace/symbol until a specific symbol appears in the index. + + Sends a hover to trigger compilation/indexing, then polls every second. + Returns True if the symbol was found, False on timeout. + """ + await client.text_document_hover_async( + HoverParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=0, character=0), + ) + ) + + for _ in range(timeout): + result = await client.workspace_symbol_async( + WorkspaceSymbolParams(query=symbol_name) + ) + if result and any(s.name == symbol_name for s in result): + return True + await asyncio.sleep(1) + return False diff --git a/tests/integration/utils/workspace.py b/tests/integration/utils/workspace.py new file mode 100644 index 000000000..ffd0d51b1 --- /dev/null +++ b/tests/integration/utils/workspace.py @@ -0,0 +1,65 @@ +"""Workspace and file utilities for integration tests.""" + +import json +from pathlib import Path + +from lsprotocol.types import ( + DidChangeTextDocumentParams, + TextDocumentContentChangeWholeDocument, + TextDocumentIdentifier, + VersionedTextDocumentIdentifier, +) + + +def doc(uri: str) -> TextDocumentIdentifier: + """Create a TextDocumentIdentifier from a URI string.""" + return TextDocumentIdentifier(uri=uri) + + +def write_cdb( + workspace: Path, + files: list[str], + *, + extra_args: list[str] | None = None, + std: str = "c++17", +) -> None: + """Write a compile_commands.json for the given source files. + + Args: + workspace: Root directory of the workspace. + files: List of source file names (relative to workspace). + extra_args: Additional compiler arguments (e.g. ["-DFOO", "-I/bar"]). + std: C++ standard version (default: c++17). + """ + entries = [] + for f in files: + args = ["clang++", f"-std={std}", "-fsyntax-only"] + if extra_args: + args.extend(extra_args) + args.append(str(workspace / f)) + entries.append( + { + "directory": str(workspace), + "file": str(workspace / f), + "arguments": args, + } + ) + (workspace / "compile_commands.json").write_text(json.dumps(entries, indent=2)) + + +def write_source(workspace: Path, name: str, content: str) -> Path: + """Write a source file to the workspace directory. Returns the file path.""" + path = workspace / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return path + + +def did_change(client, uri: str, version: int, text: str) -> None: + """Send a didChange notification with whole-document replacement.""" + client.text_document_did_change( + DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier(uri=uri, version=version), + content_changes=[TextDocumentContentChangeWholeDocument(text=text)], + ) + )