Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5fdd857
refactor(tests): unify TempFile→TempDir, extract shared helpers
16bit-ykiko Mar 31, 2026
3241c5e
refactor(tests): move LSP helpers to CliceClient methods
16bit-ykiko Mar 31, 2026
e5e60f5
refactor(tests): use CMake+Ninja to generate CDB for module tests
16bit-ykiko Mar 31, 2026
217982e
fix(tests): address CodeRabbit review comments
16bit-ykiko Mar 31, 2026
76dd648
fix(tests): use CMake 3.28 FILE_SET CXX_MODULES for proper module sup…
16bit-ykiko Mar 31, 2026
dd7f31d
fix(tests): stop copying CDB from build/ to workspace root
16bit-ykiko Mar 31, 2026
8fa3e11
fix(tests): use CMake 3.28 FILE_SET CXX_MODULES for proper module sup…
16bit-ykiko Mar 31, 2026
abf08c7
refactor(tests): add @pytest.mark.workspace decorator for cleaner tes…
16bit-ykiko Mar 31, 2026
9c435b1
refactor(tests): auto-initialize client in fixture, add type annotations
16bit-ykiko Mar 31, 2026
7047cb4
style(tests): remove type annotations from test function parameters
16bit-ykiko Mar 31, 2026
7a22571
fix(tests): use AsyncGenerator for client fixture return type
16bit-ykiko Mar 31, 2026
b89b8b8
refactor(tests): use asyncio_mode=auto, clean up redundant comments, …
16bit-ykiko Mar 31, 2026
40c3450
fix(tests): fix cmake CDB generation on macOS CI
16bit-ykiko Mar 31, 2026
8555072
fix(tests): correct expected error line in test_module_compile_error
16bit-ykiko Mar 31, 2026
c135864
fix(tests): use project toolchain for CDB generation on macOS CI
16bit-ykiko Mar 31, 2026
b7c1fa2
fix(tests): disable CMAKE_CXX_SCAN_FOR_MODULES for Windows CI
16bit-ykiko Mar 31, 2026
aa4e3dd
refactor: use clang++ instead of clang-cl on Windows
16bit-ykiko Mar 31, 2026
5f95196
fix(tests): address CodeRabbit review feedback
16bit-ykiko Mar 31, 2026
10b56f8
refactor: use clang++ instead of clang-cl on Windows
16bit-ykiko Mar 31, 2026
ce1aabd
fix: restore clang-cl ASan linking logic alongside clang++ path
16bit-ykiko Mar 31, 2026
0f945c5
refactor: simplify toolchain and fix ASan detection for clang++ on Wi…
16bit-ykiko Mar 31, 2026
6d17812
fix(windows): disable ICF in Debug to avoid ASan ODR false positives
16bit-ykiko Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,8 @@ endif()
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
add_compile_options(-fsanitize=address)

if(NOT WIN32)
string(APPEND CMAKE_EXE_LINKER_FLAGS " -fsanitize=address")
string(APPEND CMAKE_SHARED_LINKER_FLAGS " -fsanitize=address")
endif()

if(MSVC AND CMAKE_CXX_COMPILER_ID MATCHES "Clang")
if(CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC")
# clang-cl (MSVC frontend): manually link ASan runtime.
execute_process(
COMMAND ${CMAKE_CXX_COMPILER} --print-resource-dir
OUTPUT_VARIABLE CLANG_RESOURCE_DIR
Expand All @@ -47,7 +43,6 @@ if(CMAKE_BUILD_TYPE STREQUAL "Debug")
link_directories(${ASAN_LIB_PATH})

set(ASAN_LINK_FLAGS "")

list(APPEND ASAN_LINK_FLAGS "clang_rt.asan_dynamic-x86_64.lib")
list(APPEND ASAN_LINK_FLAGS "/wholearchive:clang_rt.asan_dynamic_runtime_thunk-x86_64.lib")

Expand All @@ -56,6 +51,16 @@ if(CMAKE_BUILD_TYPE STREQUAL "Debug")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} ${flag}")
set(CMAKE_MODULE_LINKER_FLAGS "${CMAKE_MODULE_LINKER_FLAGS} ${flag}")
endforeach()
else()
# GNU frontend (clang++/gcc): -fsanitize=address handles linking automatically.
string(APPEND CMAKE_EXE_LINKER_FLAGS " -fsanitize=address")
string(APPEND CMAKE_SHARED_LINKER_FLAGS " -fsanitize=address")
Comment thread
16bit-ykiko marked this conversation as resolved.
endif()

if(WIN32)
# Disable Identical COMDAT Folding in Debug to avoid ASan ODR false positives.
string(APPEND CMAKE_EXE_LINKER_FLAGS " -Wl,/OPT:NOICF")
string(APPEND CMAKE_SHARED_LINKER_FLAGS " -Wl,/OPT:NOICF")
endif()
endif()

Expand Down
22 changes: 7 additions & 15 deletions cmake/toolchain.cmake
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
cmake_minimum_required(VERSION 3.30)

set(CMAKE_C_COMPILER clang CACHE STRING "C compiler")
set(CMAKE_CXX_COMPILER clang++ CACHE STRING "C++ compiler")
set(AR_PROGRAM_NAME "llvm-ar")
set(CMAKE_EXE_LINKER_FLAGS "-fuse-ld=lld" CACHE STRING "Executable linker flags")
set(CMAKE_SHARED_LINKER_FLAGS "-fuse-ld=lld" CACHE STRING "Shared library linker flags")
set(CMAKE_MODULE_LINKER_FLAGS "-fuse-ld=lld" CACHE STRING "Module linker flags")

if(WIN32)
set(CMAKE_C_COMPILER clang-cl CACHE STRING "C compiler")
set(CMAKE_CXX_COMPILER clang-cl CACHE STRING "C++ compiler")
set(AR_PROGRAM_NAME "llvm-lib")
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreadedDLL" CACHE STRING "MSVC runtime")

find_program(LLVM_LLD_LINK_PATH "lld-link")
if(LLVM_LLD_LINK_PATH)
set(CMAKE_LINKER "${LLVM_LLD_LINK_PATH}" CACHE FILEPATH "Linker")
endif()
else()
set(CMAKE_C_COMPILER clang CACHE STRING "C compiler")
set(CMAKE_CXX_COMPILER clang++ CACHE STRING "C++ compiler")
set(AR_PROGRAM_NAME "llvm-ar")
set(CMAKE_EXE_LINKER_FLAGS "-fuse-ld=lld" CACHE STRING "Executable linker flags")
set(CMAKE_SHARED_LINKER_FLAGS "-fuse-ld=lld" CACHE STRING "Shared library linker flags")
set(CMAKE_MODULE_LINKER_FLAGS "-fuse-ld=lld" CACHE STRING "Module linker flags")
endif()

find_program(LLVM_AR_PATH ${AR_PROGRAM_NAME})
Expand Down
2 changes: 1 addition & 1 deletion pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ prettier = "*"
clang-format = "==21.1.7"

[feature.format.tasks]
format-cpp = "fd -H -e cpp -e h -e cc -e hpp -x clang-format -i"
format-cpp = "fd -H -e cpp -e h -e cc -e hpp -e cppm -x clang-format -i"
format-python = "ruff format ."
format-lua = "stylua ."
format-web = "fd -H -e js -e ts -e css -x prettier --write"
Expand Down
2 changes: 1 addition & 1 deletion scripts/activate_asan.bat
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
@echo off
for /f "delims=" %%i in ('clang-cl --print-resource-dir') do set "CLANG_RES=%%i"
for /f "delims=" %%i in ('clang++ --print-resource-dir') do set "CLANG_RES=%%i"
set "PATH=%CLANG_RES%\lib\windows;%PATH%"
127 changes: 114 additions & 13 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
"""Fixtures for clice LSP integration tests using pygls LanguageClient."""
"""Fixtures and shared helpers for clice LSP integration tests using pygls LanguageClient."""

import json
import asyncio
import json
import shutil
import subprocess
import sys
from collections.abc import AsyncGenerator
from pathlib import Path

import pytest
import pytest_asyncio
from lsprotocol.types import (
PROGRESS,
TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS,
WINDOW_WORK_DONE_PROGRESS_CREATE,
ClientCapabilities,
Diagnostic,
DidOpenTextDocumentParams,
InitializeParams,
InitializeResult,
InitializedParams,
ProgressParams,
PublishDiagnosticsParams,
TextDocumentItem,
WorkDoneProgressCreateParams,
WorkspaceFolder,
)
from pygls.lsp.client import BaseLanguageClient


def pytest_addoption(parser: pytest.Parser):
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption(
"--executable",
required=False,
Expand Down Expand Up @@ -53,27 +58,28 @@ def pytest_addoption(parser: pytest.Parser):
class CliceClient(BaseLanguageClient):
"""Language client that tracks server-sent notifications."""

def __init__(self):
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):
def on_diagnostics(params: PublishDiagnosticsParams) -> None:
self.diagnostics[params.uri] = list(params.diagnostics)
if params.uri in self.diagnostics_events:
self.diagnostics_events[params.uri].set()

@self.feature(WINDOW_WORK_DONE_PROGRESS_CREATE)
def on_create_progress(params: WorkDoneProgressCreateParams):
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):
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})

Expand All @@ -85,9 +91,56 @@ def wait_for_diagnostics(self, uri: str) -> asyncio.Event:
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 (uri, content)."""
content = filepath.read_text(encoding="utf-8")
uri = filepath.as_uri()
self.text_document_did_open(
DidOpenTextDocumentParams(
text_document=TextDocumentItem(
uri=uri, language_id="cpp", version=version, text=content
)
)
)
return uri, content

async def wait_diagnostics(self, uri: str, timeout: float = 30.0) -> None:
"""Wait for diagnostics on the given 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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async def open_and_wait(
self, filepath: Path, timeout: float = 60.0
) -> tuple[str, str]:
"""Open a file and wait for compilation diagnostics."""
uri = filepath.as_uri()
event = self.wait_for_diagnostics(uri)
_, content = self.open(filepath)
await asyncio.wait_for(event.wait(), timeout=timeout)
return uri, content


@pytest.fixture(scope="session")
def executable(request) -> Path:
def executable(request: pytest.FixtureRequest) -> Path:
exe = request.config.getoption("--executable")
if not exe:
pytest.skip("--executable not provided")
Expand All @@ -108,7 +161,7 @@ def executable(request) -> Path:


@pytest.fixture(scope="session")
def test_data_dir():
def test_data_dir() -> Path:
path = Path(__file__).parent / "data"
data_dir = path.resolve()

Expand All @@ -134,9 +187,54 @@ def test_data_dir():
return data_dir


@pytest_asyncio.fixture
async def client(request, executable: Path, test_data_dir: Path):
"""Spawn clice server, yield pygls client, then shutdown+exit."""
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.

If the workspace contains a CMakeLists.txt, automatically runs cmake
to generate compile_commands.json. Returns None if no marker is present.
"""
marker = request.node.get_closest_marker("workspace")
if marker is None:
return None
if not marker.args or not isinstance(marker.args[0], str):
raise pytest.UsageError(
"@pytest.mark.workspace requires a string argument, e.g. "
'@pytest.mark.workspace("modules/hello_world")'
)
path = test_data_dir / marker.args[0]
if (path / "CMakeLists.txt").exists():
generate_cdb(path)
return path
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@pytest.fixture
async def client(
request: pytest.FixtureRequest, executable: Path, workspace: Path | None
):
"""Spawn clice server, auto-initialize if @pytest.mark.workspace is present."""
config = request.config
mode = config.getoption("--mode")

Expand All @@ -149,6 +247,9 @@ async def client(request, executable: Path, test_data_dir: Path):
c = CliceClient()
await c.start_io(*cmd)

if workspace is not None:
await c.initialize(workspace)

yield c

# Graceful shutdown
Expand Down
13 changes: 13 additions & 0 deletions tests/data/modules/chained_modules/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
cmake_minimum_required(VERSION 3.28)
project(chained_modules LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

add_library(test)
target_sources(test
PUBLIC
FILE_SET CXX_MODULES
FILES mod_a.cppm mod_b.cppm
)

5 changes: 4 additions & 1 deletion tests/data/modules/chained_modules/mod_a.cppm
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
export module A;
export int foo() { return 42; }

export int foo() {
return 42;
}
5 changes: 4 additions & 1 deletion tests/data/modules/chained_modules/mod_b.cppm
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export module B;
import A;
export int bar() { return foo() + 1; }

export int bar() {
return foo() + 1;
}
13 changes: 13 additions & 0 deletions tests/data/modules/circular_module_dependency/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
cmake_minimum_required(VERSION 3.28)
project(circular_module_dependency LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

add_library(test)
target_sources(test
PUBLIC
FILE_SET CXX_MODULES
FILES cycle_a.cppm cycle_b.cppm ok.cppm
)

5 changes: 4 additions & 1 deletion tests/data/modules/circular_module_dependency/cycle_a.cppm
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export module CycA;
import CycB;
export int a() { return 1; }

export int a() {
return 1;
}
5 changes: 4 additions & 1 deletion tests/data/modules/circular_module_dependency/cycle_b.cppm
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export module CycB;
import CycA;
export int b() { return 2; }

export int b() {
return 2;
}
5 changes: 4 additions & 1 deletion tests/data/modules/circular_module_dependency/ok.cppm
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
export module Ok;
export int ok() { return 42; }

export int ok() {
return 42;
}
13 changes: 13 additions & 0 deletions tests/data/modules/class_export_and_inheritance/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
cmake_minimum_required(VERSION 3.28)
project(class_export_and_inheritance LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

add_library(test)
target_sources(test
PUBLIC
FILE_SET CXX_MODULES
FILES shape.cppm circle.cppm
)

7 changes: 6 additions & 1 deletion tests/data/modules/class_export_and_inheritance/circle.cppm
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
export module Circle;
import Shape;

export class Circle : public Shape {
int r;

public:
Circle(int r) : r(r) {}
int area() const override { return 3 * r * r; }

int area() const override {
return 3 * r * r;
}
};
1 change: 1 addition & 0 deletions tests/data/modules/class_export_and_inheritance/shape.cppm
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export module Shape;

export class Shape {
public:
virtual ~Shape() = default;
Expand Down
Loading
Loading