diff --git a/.github/workflows/test-cmake.yml b/.github/workflows/test-cmake.yml index 778d5220..6d0f455a 100644 --- a/.github/workflows/test-cmake.yml +++ b/.github/workflows/test-cmake.yml @@ -20,5 +20,8 @@ jobs: - name: Build run: pixi run build ${{ matrix.build_type }} ON - - name: Test + - name: Unit Test run: pixi run unit-test ${{ matrix.build_type }} + + - name: Integration Test + run: pixi run integration-test ${{ matrix.build_type }} diff --git a/.gitignore b/.gitignore index 1caf2a78..33dba7a8 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,7 @@ tests/unit/Local/ .env .pixi/* !.pixi/config.toml + +.codex/ +.claude/ +openspec/ \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index f8124be2..4d386f47 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -101,7 +101,7 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") endif() if(MSVC OR (CMAKE_CXX_COMPILER_ID MATCHES "Clang" AND - CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC")) + CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC")) target_compile_options(clice_options INTERFACE /GR- /EHsc- @@ -111,9 +111,9 @@ else() target_compile_options(clice_options INTERFACE -fno-rtti -Wno-deprecated-declarations - -Wno-undefined-inline -ffunction-sections -fdata-sections + $<$:-Wno-undefined-inline> ) endif() @@ -164,6 +164,11 @@ add_library(clice-core STATIC "${PROJECT_SOURCE_DIR}/src/index/usr_generation.cpp" "${PROJECT_SOURCE_DIR}/src/index/project_index.cpp" "${PROJECT_SOURCE_DIR}/src/index/merged_index.cpp" + "${PROJECT_SOURCE_DIR}/src/server/stateless_worker.cpp" + "${PROJECT_SOURCE_DIR}/src/server/stateful_worker.cpp" + "${PROJECT_SOURCE_DIR}/src/server/worker_pool.cpp" + "${PROJECT_SOURCE_DIR}/src/server/master_server.cpp" + "${PROJECT_SOURCE_DIR}/src/server/config.cpp" ) add_library(clice::core ALIAS clice-core) add_dependencies(clice-core generate_flatbuffers_schema) @@ -178,12 +183,12 @@ target_link_libraries(clice-core PUBLIC spdlog::spdlog roaring::roaring flatbuffers - eventide::async - eventide::language + eventide::ipc::lsp + eventide::serde::toml ) add_executable(clice "${PROJECT_SOURCE_DIR}/src/clice.cc") -target_link_libraries(clice PRIVATE clice::core) +target_link_libraries(clice PRIVATE clice::core eventide::deco) install(TARGETS clice RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) message(STATUS "Copying resource directory for development build") @@ -214,5 +219,5 @@ if(CLICE_ENABLE_TEST) "${PROJECT_SOURCE_DIR}/src" "${PROJECT_SOURCE_DIR}/tests/unit" ) - target_link_libraries(unit_tests PRIVATE clice::core eventide::zest) + target_link_libraries(unit_tests PRIVATE clice::core eventide::zest eventide::deco) endif() diff --git a/cmake/package.cmake b/cmake/package.cmake index 61fb68b7..443a80be 100644 --- a/cmake/package.cmake +++ b/cmake/package.cmake @@ -11,24 +11,18 @@ set(FETCHCONTENT_UPDATES_DISCONNECTED ON) FetchContent_Declare( spdlog GIT_REPOSITORY https://github.com/gabime/spdlog.git - GIT_TAG v1.15.3 - GIT_SHALLOW TRUE -) - -# tomlplusplus -FetchContent_Declare( - tomlplusplus - GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git - GIT_TAG v3.4.0 - GIT_SHALLOW TRUE + GIT_TAG v1.15.3 + GIT_SHALLOW TRUE ) +set(SPDLOG_USE_STD_FORMAT ON CACHE BOOL "" FORCE) +set(SPDLOG_NO_EXCEPTIONS ON CACHE BOOL "" FORCE) # croaring FetchContent_Declare( croaring GIT_REPOSITORY https://github.com/RoaringBitmap/CRoaring.git - GIT_TAG v4.4.2 - GIT_SHALLOW TRUE + GIT_TAG v4.4.2 + GIT_SHALLOW TRUE ) set(ENABLE_ROARING_TESTS OFF CACHE INTERNAL "" FORCE) set(ENABLE_ROARING_MICROBENCHMARKS OFF CACHE INTERNAL "" FORCE) @@ -37,8 +31,8 @@ set(ENABLE_ROARING_MICROBENCHMARKS OFF CACHE INTERNAL "" FORCE) FetchContent_Declare( flatbuffers GIT_REPOSITORY https://github.com/google/flatbuffers.git - GIT_TAG v25.9.23 - GIT_SHALLOW TRUE + GIT_TAG v25.9.23 + GIT_SHALLOW TRUE ) set(FLATBUFFERS_BUILD_GRPC OFF CACHE BOOL "" FORCE) set(FLATBUFFERS_BUILD_TESTS OFF CACHE BOOL "" FORCE) @@ -47,17 +41,16 @@ set(FLATBUFFERS_BUILD_FLATHASH OFF CACHE BOOL "" FORCE) FetchContent_Declare( eventide GIT_REPOSITORY https://github.com/clice-io/eventide - GIT_TAG main - GIT_SHALLOW TRUE + GIT_TAG main + GIT_SHALLOW TRUE ) -set(EVENTIDE_ENABLE_ZEST ON) -set(EVENTIDE_ENABLE_TEST OFF) -set(EVENTIDE_SERDE_ENABLE_SIMDJSON ON) -set(EVENTIDE_SERDE_ENABLE_YYJSON ON) -FetchContent_MakeAvailable(eventide spdlog tomlplusplus croaring flatbuffers) +set(ETD_ENABLE_ZEST ON) +set(ETD_ENABLE_TEST OFF) +set(ETD_SERDE_ENABLE_SIMDJSON ON) +set(ETD_SERDE_ENABLE_YYJSON ON) +set(ETD_SERDE_ENABLE_TOML ON) +set(ETD_ENABLE_EXCEPTIONS OFF) +set(ETD_ENABLE_RTTI OFF) -target_compile_definitions(spdlog PUBLIC - SPDLOG_USE_STD_FORMAT=1 - SPDLOG_NO_EXCEPTIONS=1 -) +FetchContent_MakeAvailable(eventide spdlog croaring flatbuffers) diff --git a/docs/en/architecture.md b/docs/en/architecture.md new file mode 100644 index 00000000..b102f209 --- /dev/null +++ b/docs/en/architecture.md @@ -0,0 +1,183 @@ +# Server Architecture + +clice uses a **multi-process architecture** where a single **Master Server** coordinates multiple **Worker** processes. This design isolates Clang AST operations (which are memory-heavy and may crash) from the main LSP event loop. + +## Overview + +``` +┌──────────────┐ JSON/LSP ┌────────────────┐ Bincode/IPC ┌──────────────────┐ +│ LSP Client │ ◄──────────► │ Master Server │ ◄─────────────► │ Stateful Workers │ +│ (Editor) │ (stdio) │ │ (stdio) │ (AST cache) │ +└──────────────┘ │ - Lifecycle │ └──────────────────┘ + │ - Documents │ + │ - CDB │ Bincode/IPC ┌──────────────────┐ + │ - Build drain │ ◄─────────────► │ Stateless Workers│ + │ - Indexing │ (stdio) │ (one-shot tasks)│ + └────────────────┘ └──────────────────┘ +``` + +## Master Server + +The master server (`src/server/master_server.cpp`) is the central coordinator. It runs a single-threaded async event loop and never touches Clang directly. Its responsibilities: + +### LSP Lifecycle + +The server progresses through these states: + +1. **Uninitialized** — waiting for `initialize` request +2. **Initialized** — capabilities exchanged, waiting for `initialized` notification +3. **Ready** — workers spawned, workspace loaded, accepting requests +4. **ShuttingDown** — `shutdown` received, draining work +5. **Exited** — `exit` received, stopping the event loop + +On `initialized`, the master: + +- Loads configuration from `clice.toml` (or uses defaults) +- Starts the worker pool (spawns stateful + stateless processes) +- Loads `compile_commands.json` and builds an include graph +- Starts the background indexer coroutine (if enabled) + +### Document Management + +Each open document is tracked in a `DocumentState` with: + +- Current `version` and `text` (kept in sync via `didOpen`/`didChange`) +- A `generation` counter to detect stale compile results +- Build state flags (`build_running`, `build_requested`, `drain_scheduled`) + +When a document is opened or changed: + +1. The include graph is re-scanned (via dependency directives) +2. The compile unit is registered/updated in the `CompileGraph` +3. A debounced build is scheduled + +### Build Drain + +The `run_build_drain` coroutine implements debounced compilation: + +1. Wait for the debounce timer (default 200ms) to expire +2. Ensure PCH/PCM dependencies are ready via `CompileGraph` +3. Send a `compile` request to the assigned stateful worker +4. Publish diagnostics from the result (or clear them on failure) +5. If more edits arrived during compilation (`build_requested`), loop back to step 2 + +This ensures rapid typing doesn't trigger a compile per keystroke. + +### Request Routing + +Feature requests are split between two worker types: + +**Stateful workers** (affinity-routed by file path): + +- `textDocument/hover` +- `textDocument/semanticTokens/full` +- `textDocument/inlayHint` +- `textDocument/foldingRange` +- `textDocument/documentSymbol` +- `textDocument/documentLink` +- `textDocument/codeAction` +- `textDocument/definition` + +**Stateless workers** (round-robin): + +- `textDocument/completion` +- `textDocument/signatureHelp` + +All feature responses use `RawValue` passthrough — the worker serializes the LSP result to JSON, and the master forwards the raw JSON bytes to the client without deserializing. This avoids bincode↔JSON conversion overhead and serde annotation conflicts. + +## Worker Pool + +The worker pool (`src/server/worker_pool.cpp`) manages spawning and communicating with worker processes. Each worker is a child process of the same `clice` binary, launched with `--mode stateful-worker` or `--mode stateless-worker`. + +### Communication + +Workers communicate with the master via **stdio pipes** using a **bincode** serialization format (via `eventide::ipc::BincodePeer`). This is more compact and faster than JSON for internal IPC, while the master handles JSON for the external LSP protocol. + +### Stateful Worker Routing + +Stateful workers use **affinity routing**: each file is consistently assigned to the same worker so that the worker retains the cached AST. Assignment uses a **least-loaded** strategy for new files, with **LRU tracking** to manage ownership. + +When a worker exceeds its document capacity (currently hardcoded at 16 documents), it evicts the least-recently-used document and notifies the master via an `evicted` notification. + +### Stateless Worker Routing + +Stateless workers use simple **round-robin** dispatch. Each request includes the full source text and compilation arguments, so any worker can handle it independently. + +## Stateful Worker + +The stateful worker (`src/server/stateful_worker.cpp`) caches compiled ASTs in memory. Key behavior: + +- **Compile**: Parses source code into a `CompilationUnit`, caches the AST, and returns diagnostics as a `RawValue` (JSON bytes) +- **Feature queries**: Look up the cached AST and invoke the corresponding `feature::*` function (hover, semantic tokens, etc.), serializing the result to JSON +- **Document updates**: Received as notifications — the worker updates the stored text and marks the document as `dirty`, causing feature queries to return `null` until recompilation +- **Eviction**: LRU-based; evicts the oldest document when capacity is exceeded, notifying the master +- **Concurrency**: Each document has a per-document `et::mutex` (strand) to serialize compilation and feature queries. Heavy work (compilation, feature extraction) runs on a thread pool via `et::queue`. + +## Stateless Worker + +The stateless worker (`src/server/stateless_worker.cpp`) handles one-shot requests that don't benefit from cached ASTs: + +- **Completion**: Creates a fresh compilation with `CompilationKind::Completion` and invokes `feature::code_complete` +- **Signature help**: Similar to completion, using `feature::signature_help` +- **Build PCH**: Compiles a precompiled header to a temporary file +- **Build PCM**: Compiles a C++20 module interface to a temporary file +- **Index**: Compiles a file for indexing (TUIndex generation — currently a stub) + +All requests are dispatched to a thread pool via `et::queue`. + +## Compile Graph + +The compile graph (`src/server/compile_graph.cpp`) tracks compilation unit dependencies as a DAG. It handles: + +- **Registration**: Each file registers its included dependencies +- **Cascade invalidation**: When a file changes, all transitive dependents are marked dirty and their ongoing compilations are cancelled +- **Dependency compilation**: Before compiling a file, `compile_deps` ensures all dependencies (PCH, PCMs) are built first +- **Cancellation**: Uses `et::cancellation_source` to abort in-flight compilations when files are invalidated + +## Configuration + +The server reads configuration from `clice.toml` (or `.clice/config.toml`) in the workspace root. If no config file exists, sensible defaults are computed from system resources: + +| Setting | Default | Description | +| ------------------------ | --------------------- | ------------------------------------------- | +| `stateful_worker_count` | CPU cores / 4 | Number of stateful worker processes | +| `stateless_worker_count` | CPU cores / 4 | Number of stateless worker processes | +| `worker_memory_limit` | 4 GB | Memory limit per stateful worker | +| `compile_commands_path` | auto-detect | Path to `compile_commands.json` | +| `cache_dir` | `/.clice/` | Cache directory for PCH/PCM files | +| `debounce_ms` | 200 | Debounce interval for recompilation | +| `enable_indexing` | true | Enable background indexing | +| `idle_timeout_ms` | 3000 | Idle time before background indexing starts | + +String values support `${workspace}` substitution. + +## IPC Protocol + +The master and workers communicate using custom RPC messages defined in `src/server/protocol.h`. Each message type has a `RequestTraits` or `NotificationTraits` specialization that defines the method name and result type. + +### Stateful Worker Messages + +| Method | Direction | Purpose | +| ----------------------------- | ------------ | ------------------------------------- | +| `clice/worker/compile` | Request | Compile source and return diagnostics | +| `clice/worker/hover` | Request | Get hover info at position | +| `clice/worker/semanticTokens` | Request | Get semantic tokens for file | +| `clice/worker/inlayHints` | Request | Get inlay hints for range | +| `clice/worker/foldingRange` | Request | Get folding ranges | +| `clice/worker/documentSymbol` | Request | Get document symbols | +| `clice/worker/documentLink` | Request | Get document links | +| `clice/worker/codeAction` | Request | Get code actions for range | +| `clice/worker/goToDefinition` | Request | Go to definition at position | +| `clice/worker/documentUpdate` | Notification | Update document text (marks dirty) | +| `clice/worker/evict` | Notification | Master → Worker: evict a document | +| `clice/worker/evicted` | Notification | Worker → Master: document was evicted | + +### Stateless Worker Messages + +| Method | Direction | Purpose | +| ---------------------------- | --------- | ---------------------------- | +| `clice/worker/completion` | Request | Code completion at position | +| `clice/worker/signatureHelp` | Request | Signature help at position | +| `clice/worker/buildPCH` | Request | Build precompiled header | +| `clice/worker/buildPCM` | Request | Build C++20 module interface | +| `clice/worker/index` | Request | Index a translation unit | diff --git a/pixi.lock b/pixi.lock index bdbbd336..a72e3a7c 100644 --- a/pixi.lock +++ b/pixi.lock @@ -70,12 +70,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda @@ -121,12 +126,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl win-64: - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-h4c7d964_0.conda @@ -164,13 +174,18 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.44.35208-h818238b_33.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.44.35208-h818238b_33.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda + - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl format: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -403,12 +418,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/xz-gpl-tools-5.8.1-hbcc6ac9_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/xz-tools-5.8.1-hb9d3cd8_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda @@ -457,12 +477,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xz-gpl-tools-5.8.1-h9a6d368_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xz-tools-5.8.1-h39f12f2_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl win-64: - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-h4c7d964_0.conda @@ -502,13 +527,18 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/xz-5.8.1-h208afaa_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/xz-tools-5.8.1-h2466b09_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda + - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl packages: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726 @@ -531,6 +561,11 @@ packages: purls: [] size: 23621 timestamp: 1650670423406 +- pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + name: attrs + version: 26.1.0 + sha256: c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 + requires_python: '>=3.9' - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45-default_hfdba357_104.conda sha256: 054a77ccab631071a803737ea8e5d04b5b18e57db5b0826a04495bd3fdf39a7c md5: a7a67bf132a4a2dea92a7cb498cdc5b1 @@ -626,6 +661,25 @@ packages: purls: [] size: 152432 timestamp: 1762967197890 +- pypi: https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl + name: cattrs + version: 26.1.0 + sha256: d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096 + requires_dist: + - attrs>=25.4.0 + - exceptiongroup>=1.1.1 ; python_full_version < '3.11' + - typing-extensions>=4.14.0 + - pymongo>=4.4.0 ; extra == 'bson' + - cbor2>=5.4.6 ; extra == 'cbor2' + - msgpack>=1.0.5 ; extra == 'msgpack' + - msgspec>=0.19.0 ; implementation_name == 'cpython' and extra == 'msgspec' + - orjson>=3.11.3 ; implementation_name == 'cpython' and extra == 'orjson' + - pyyaml>=6.0 ; extra == 'pyyaml' + - tomlkit>=0.11.8 ; extra == 'tomlkit' + - tomli-w>=1.1.0 ; extra == 'tomllib' + - tomli>=1.1.0 ; python_full_version < '3.11' and extra == 'tomllib' + - ujson>=5.10.0 ; extra == 'ujson' + requires_python: '>=3.10' - conda: https://conda.anaconda.org/conda-forge/linux-64/clang-20.1.8-default_h36abe19_5.conda sha256: dfce7b8de6be7a988dab1092e2ad54fc544a572cc5ac7df28f8f48ab409814bc md5: b6ab4982173dcae265d545bec3a76a6c @@ -2413,6 +2467,14 @@ packages: purls: [] size: 17569405 timestamp: 1757354613846 +- pypi: https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl + name: lsprotocol + version: 2025.0.0 + sha256: f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7 + requires_dist: + - attrs>=21.3.0 + - cattrs!=23.2.1 + requires_python: '>=3.8' - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586 md5: 47e340acb35de30501a76c7c799c41d7 @@ -2590,10 +2652,10 @@ packages: purls: [] size: 9440812 timestamp: 1762841722179 -- pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl name: packaging - version: '25.0' - sha256: 29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 + version: '26.0' + sha256: b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529 requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl name: pluggy @@ -2676,6 +2738,16 @@ packages: license_family: MIT size: 1105059 timestamp: 1764738810596 +- pypi: https://files.pythonhosted.org/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl + name: pygls + version: 2.1.0 + sha256: cfa8443561488cb15b59f6ce64cabfa37d79753f7120c1bf729419246bf747f9 + requires_dist: + - attrs>=24.3.0 + - cattrs>=23.1.2 + - lsprotocol==2025.0.0 + - websockets>=13.0 ; extra == 'ws' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl name: pygments version: 2.19.2 @@ -2999,6 +3071,11 @@ packages: license: MIT size: 6535168 timestamp: 1766863032287 +- pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl + name: typing-extensions + version: 4.15.0 + sha256: f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 + requires_python: '>=3.9' - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h8577fbf_1.conda sha256: 865716d3e2ccaca1218462645830d2370ab075a9a118c238728e1231a234bc6c md5: e4e8496b68cf5f25e76fbe67f3856550 diff --git a/pixi.toml b/pixi.toml index 03995186..44a1183b 100644 --- a/pixi.toml +++ b/pixi.toml @@ -49,6 +49,8 @@ scripts = ["scripts/activate_asan.bat"] [feature.test.pypi-dependencies] pytest = "*" pytest-asyncio = ">=1.1.0" +pygls = ">=2.0.0" +lsprotocol = ">=2024.0.0" [feature.package.dependencies] xz = ">=5.8.1,<6" diff --git a/src/clice.cc b/src/clice.cc index 120fcc99..cec3475d 100644 --- a/src/clice.cc +++ b/src/clice.cc @@ -1,3 +1,160 @@ +#include +#include +#include +#include + +#include "eventide/async/async.h" +#include "eventide/deco/macro.h" +#include "eventide/deco/runtime.h" +#include "eventide/ipc/peer.h" +#include "eventide/ipc/transport.h" +#include "server/master_server.h" +#include "server/stateful_worker.h" +#include "server/stateless_worker.h" +#include "support/filesystem.h" +#include "support/logging.h" + +namespace clice { + +struct Options { + DecoKV(names = {"--mode"}; + help = "Running mode: pipe, socket, stateless-worker, stateful-worker"; + required = false;) + mode; + + DecoKV(names = {"--host"}; help = "Socket mode address"; required = false;) + host = "127.0.0.1"; + + DecoKV(names = {"--port"}; help = "Socket mode port"; required = false;) + port = 50051; + + DecoKV(names = {"--stateful-worker-count"}; help = "Number of stateful workers"; + required = false;) + stateful_worker_count; + + DecoKV(names = {"--stateless-worker-count"}; help = "Number of stateless workers"; + required = false;) + stateless_worker_count; + + DecoKV(names = {"--worker-memory-limit"}; help = "Memory limit per stateful worker (bytes)"; + required = false;) + worker_memory_limit; + + DecoFlag(names = {"-h", "--help"}; help = "Show help message"; required = false;) + help; + + DecoFlag(names = {"-v", "--version"}; help = "Show version"; required = false;) + version; +}; + +} // namespace clice + int main(int argc, const char** argv) { - return 0; + auto args = deco::util::argvify(argc, argv); + auto result = deco::cli::parse(args); + + if(!result.has_value()) { + LOG_ERROR("{}", result.error().message); + return 1; + } + + auto& opts = result->options; + + if(opts.help.value_or(false)) { + auto dispatcher = deco::cli::Dispatcher("clice [OPTIONS]"); + std::ostringstream oss; + dispatcher.usage(oss, true); + std::print("{}", oss.str()); + return 0; + } + + if(opts.version.value_or(false)) { + std::println("clice version 0.1.0"); + return 0; + } + + if(!opts.mode.has_value()) { + LOG_ERROR("--mode is required"); + return 1; + } + + std::string self_path = llvm::sys::fs::getMainExecutable(argv[0], (void*)main); + if(!clice::fs::init_resource_dir(self_path)) { + LOG_ERROR("Cannot find the resource dir: {}", self_path); + } + + auto& mode = *opts.mode; + + if(mode == "stateless-worker") { + return clice::run_stateless_worker_mode(); + } + + if(mode == "stateful-worker") { + auto mem_limit = opts.worker_memory_limit.value_or(4ULL * 1024 * 1024 * 1024); + return clice::run_stateful_worker_mode(mem_limit); + } + + if(mode == "pipe") { + clice::logging::stderr_logger("master", clice::logging::options); + + namespace et = eventide; + et::event_loop loop; + + auto transport = et::ipc::StreamTransport::open_stdio(loop); + if(!transport) { + LOG_ERROR("failed to open stdio transport"); + return 1; + } + + et::ipc::JsonPeer peer(loop, std::move(*transport)); + clice::MasterServer server(loop, peer, std::move(self_path)); + server.register_handlers(); + + loop.schedule(peer.run()); + return loop.run(); + } + + if(mode == "socket") { + clice::logging::stderr_logger("master", clice::logging::options); + + namespace et = eventide; + et::event_loop loop; + + auto host = opts.host.value_or("127.0.0.1"); + auto port = opts.port.value_or(50051); + + auto acceptor = et::tcp::listen(host, port, {}, loop); + if(!acceptor) { + LOG_ERROR("failed to listen on {}:{}", host, port); + return 1; + } + + LOG_INFO("Listening on {}:{} ...", host, port); + + auto task = [&]() -> et::task<> { + auto client = co_await acceptor->accept(); + if(!client.has_value()) { + LOG_ERROR("failed to accept connection"); + loop.stop(); + co_return; + } + + LOG_INFO("Client connected"); + + auto transport = std::make_unique(std::move(client.value())); + et::ipc::JsonPeer peer(loop, std::move(transport)); + clice::MasterServer server(loop, peer, std::string(self_path)); + server.register_handlers(); + + co_await peer.run(); + peer.close(); + loop.stop(); + }; + + loop.schedule(task()); + return loop.run(); + } + + LOG_ERROR("unknown mode '{}'", mode); + return 1; } diff --git a/src/compile/compilation.cpp b/src/compile/compilation.cpp index 7e8312c6..a5ea7627 100644 --- a/src/compile/compilation.cpp +++ b/src/compile/compilation.cpp @@ -44,13 +44,17 @@ std::unique_ptr std::unique_ptr invocation; - /// Arguments from compilation database are already cc1 - if(params.arguments_from_database) { + /// If the second argument is "-cc1", the arguments are already expanded + /// (e.g. from compilation database + query_toolchain). Skip driver and "-cc1" + /// and create invocation directly from the cc1 args. + bool is_cc1 = params.arguments.size() >= 2 && llvm::StringRef(params.arguments[1]) == "-cc1"; + if(is_cc1) { invocation = std::make_unique(); - if(!clang::CompilerInvocation::CreateFromArgs(*invocation, - llvm::ArrayRef(params.arguments).drop_front(), - *diagnostic_engine, - params.arguments[0])) { + if(!clang::CompilerInvocation::CreateFromArgs( + *invocation, + llvm::ArrayRef(params.arguments).drop_front(2), + *diagnostic_engine, + params.arguments[0])) { LOG_ERROR_RET(nullptr, " Fail to create invocation, arguments list is: {}", print_argv(params.arguments)); diff --git a/src/compile/compilation.h b/src/compile/compilation.h index 2c3e86b1..9f3ba0ed 100644 --- a/src/compile/compilation.h +++ b/src/compile/compilation.h @@ -75,8 +75,6 @@ struct CompilationParams { std::string directory; - bool arguments_from_database = false; - /// Responsible for storing the arguments. std::vector arguments; diff --git a/src/compile/diagnostic.cpp b/src/compile/diagnostic.cpp index ad3f239a..73ad23a6 100644 --- a/src/compile/diagnostic.cpp +++ b/src/compile/diagnostic.cpp @@ -74,6 +74,8 @@ std::optional DiagnosticID::diagnostic_document_uri() const { return std::nullopt; } } + + return std::nullopt; } bool DiagnosticID::is_deprecated() const { diff --git a/src/compile/toolchain.cpp b/src/compile/toolchain.cpp index ee70abe2..ee92a4f1 100644 --- a/src/compile/toolchain.cpp +++ b/src/compile/toolchain.cpp @@ -1,6 +1,5 @@ #include "compile/toolchain.h" -#include #include #include #include @@ -383,6 +382,7 @@ std::vector query_toolchain(const QueryParams& params) { query_driver(params_copy.arguments, [&](const char* driver, llvm::ArrayRef cc1_args) { result.emplace_back(params.callback(driver)); + result.emplace_back(params.callback("-cc1")); for(auto arg: cc1_args) { result.emplace_back(params.callback(arg)); } @@ -390,6 +390,8 @@ std::vector query_toolchain(const QueryParams& params) { return result; } } + + return {}; } std::vector query_gcc_toolchain(const QueryParams& params) { @@ -417,8 +419,13 @@ std::vector query_gcc_toolchain(const QueryParams& params) { } } - target = std::format("--target={}", target); - install_path = std::format("--gcc-install-dir={}", install_path); + llvm::SmallString<64> formatted_target("--target="); + formatted_target += target; + target = formatted_target; + + llvm::SmallString<64> formatted_install_path("--gcc-install-dir="); + formatted_install_path += install_path; + install_path = formatted_install_path; query_arguments.clear(); query_arguments.emplace_back(arguments.consume_front()); @@ -429,6 +436,7 @@ std::vector query_gcc_toolchain(const QueryParams& params) { std::vector result; query_driver(query_arguments, [&](const char* driver, llvm::ArrayRef cc1_args) { result.emplace_back(params.callback(driver)); + result.emplace_back(params.callback("-cc1")); for(auto arg: cc1_args) { result.emplace_back(params.callback(arg)); } diff --git a/src/feature/code_completion.cpp b/src/feature/code_completion.cpp index 308c958b..c15e29a5 100644 --- a/src/feature/code_completion.cpp +++ b/src/feature/code_completion.cpp @@ -24,8 +24,6 @@ namespace clice::feature { namespace { -namespace protocol = eventide::language::protocol; - struct CompletionPrefix { LocalSourceRange range; llvm::StringRef spelling; diff --git a/src/feature/diagnostics.cpp b/src/feature/diagnostics.cpp index 7b1e9aa6..064125e3 100644 --- a/src/feature/diagnostics.cpp +++ b/src/feature/diagnostics.cpp @@ -2,36 +2,29 @@ #include #include -#include "eventide/language/uri.h" +#include "eventide/ipc/lsp/uri.h" #include "feature/feature.h" namespace clice::feature { namespace { -namespace protocol = eventide::language::protocol; +namespace lsp = eventide::ipc::lsp; auto to_uri(llvm::StringRef file) -> std::string { const auto file_view = std::string_view(file.data(), file.size()); - if(auto parsed = eventide::language::URI::parse(file_view)) { + if(auto parsed = lsp::URI::parse(file_view)) { return parsed->str(); } - if(auto uri = eventide::language::URI::from_file_path(file_view)) { + if(auto uri = lsp::URI::from_file_path(file_view)) { return uri->str(); } return file.str(); } -auto to_range(const PositionMapper& converter, LocalSourceRange range) -> protocol::Range { - return protocol::Range{ - .start = converter.to_position(range.begin), - .end = converter.to_position(range.end), - }; -} - void add_tag(protocol::Diagnostic& diagnostic, DiagnosticID id) { if(id.is_deprecated()) { if(!diagnostic.tags.has_value()) { diff --git a/src/feature/document_links.cpp b/src/feature/document_links.cpp index fabe57b5..8963c451 100644 --- a/src/feature/document_links.cpp +++ b/src/feature/document_links.cpp @@ -7,18 +7,7 @@ namespace clice::feature { -namespace { - -namespace protocol = eventide::language::protocol; - -auto to_range(const PositionMapper& converter, LocalSourceRange range) -> protocol::Range { - return protocol::Range{ - .start = converter.to_position(range.begin), - .end = converter.to_position(range.end), - }; -} - -} // namespace +namespace {} // namespace auto document_links(CompilationUnitRef unit, PositionEncoding encoding) -> std::vector { diff --git a/src/feature/document_symbols.cpp b/src/feature/document_symbols.cpp index 593a0a07..a9ca933b 100644 --- a/src/feature/document_symbols.cpp +++ b/src/feature/document_symbols.cpp @@ -18,15 +18,6 @@ namespace clice::feature { namespace { -namespace protocol = eventide::language::protocol; - -auto to_range(const PositionMapper& converter, LocalSourceRange range) -> protocol::Range { - return protocol::Range{ - .start = converter.to_position(range.begin), - .end = converter.to_position(range.end), - }; -} - auto to_protocol_symbol_kind(SymbolKind kind) -> protocol::SymbolKind { using enum protocol::SymbolKind; diff --git a/src/feature/feature.h b/src/feature/feature.h index a41876e2..7c0e5b48 100644 --- a/src/feature/feature.h +++ b/src/feature/feature.h @@ -7,8 +7,8 @@ #include "compile/compilation.h" #include "compile/compilation_unit.h" -#include "eventide/language/position.h" -#include "eventide/language/protocol.h" +#include "eventide/ipc/lsp/position.h" +#include "eventide/ipc/lsp/protocol.h" namespace clang { @@ -18,11 +18,18 @@ class NamedDecl; namespace clice::feature { -namespace protocol = eventide::language::protocol; +namespace protocol = eventide::ipc::protocol; -using eventide::language::PositionEncoding; -using eventide::language::PositionMapper; -using eventide::language::parse_position_encoding; +using eventide::ipc::lsp::PositionEncoding; +using eventide::ipc::lsp::PositionMapper; +using eventide::ipc::lsp::parse_position_encoding; + +inline auto to_range(const PositionMapper& converter, LocalSourceRange range) -> protocol::Range { + return protocol::Range{ + .start = converter.to_position(range.begin), + .end = converter.to_position(range.end), + }; +} struct CodeCompletionOptions { bool enable_keyword_snippet = false; diff --git a/src/feature/folding_ranges.cpp b/src/feature/folding_ranges.cpp index 0d8dd4b9..eb1c05dc 100644 --- a/src/feature/folding_ranges.cpp +++ b/src/feature/folding_ranges.cpp @@ -15,8 +15,6 @@ namespace clice::feature { namespace { -namespace protocol = eventide::language::protocol; - enum class FoldingKind : std::uint8_t { Namespace, Class, diff --git a/src/feature/formatting.cpp b/src/feature/formatting.cpp index 74398d5f..7969a987 100644 --- a/src/feature/formatting.cpp +++ b/src/feature/formatting.cpp @@ -11,8 +11,6 @@ namespace clice::feature { namespace { - -namespace protocol = eventide::language::protocol; namespace tooling = clang::tooling; auto format_content(llvm::StringRef file, llvm::StringRef content, tooling::Range range) diff --git a/src/feature/hover.cpp b/src/feature/hover.cpp index cc20bb45..6e2f9f6f 100644 --- a/src/feature/hover.cpp +++ b/src/feature/hover.cpp @@ -14,8 +14,6 @@ namespace clice::feature { namespace { -namespace protocol = eventide::language::protocol; - auto symbol_name(SymbolKind kind) -> llvm::StringRef { switch(kind) { case SymbolKind::Module: return "module"; diff --git a/src/feature/inlay_hints.cpp b/src/feature/inlay_hints.cpp index 6c0ad5dc..6264a6a0 100644 --- a/src/feature/inlay_hints.cpp +++ b/src/feature/inlay_hints.cpp @@ -20,8 +20,6 @@ namespace clice::feature { namespace { -namespace protocol = eventide::language::protocol; - using llvm::dyn_cast; using llvm::dyn_cast_or_null; diff --git a/src/feature/semantic_tokens.cpp b/src/feature/semantic_tokens.cpp index ca1b280d..c71efcdb 100644 --- a/src/feature/semantic_tokens.cpp +++ b/src/feature/semantic_tokens.cpp @@ -17,8 +17,6 @@ namespace clice::feature { namespace { -namespace protocol = eventide::language::protocol; - struct RawToken { LocalSourceRange range; SymbolKind kind = SymbolKind::Invalid; diff --git a/src/feature/signature_help.cpp b/src/feature/signature_help.cpp index 493e17e0..16a57268 100644 --- a/src/feature/signature_help.cpp +++ b/src/feature/signature_help.cpp @@ -7,8 +7,6 @@ namespace clice::feature { namespace { -namespace protocol = eventide::language::protocol; - class SignatureCollector final : public clang::CodeCompleteConsumer { public: SignatureCollector(protocol::SignatureHelp& help, clang::CodeCompleteOptions complete_options) : diff --git a/src/semantic/resolver.cpp b/src/semantic/resolver.cpp index b55187c5..e5d3348b 100644 --- a/src/semantic/resolver.cpp +++ b/src/semantic/resolver.cpp @@ -397,6 +397,8 @@ public: std::abort(); } } + + return lookup_result(); } /// Look up the name in the bases of the given class. Keep stack unchanged. diff --git a/src/server/config.cpp b/src/server/config.cpp new file mode 100644 index 00000000..89d2aa6c --- /dev/null +++ b/src/server/config.cpp @@ -0,0 +1,89 @@ +#include "server/config.h" + +#include +#include + +#include "eventide/serde/toml.h" +#include "support/filesystem.h" +#include "support/logging.h" + +namespace clice { + +/// Replace all occurrences of ${workspace} with the workspace root. +static void substitute_workspace(std::string& value, const std::string& workspace_root) { + constexpr std::string_view placeholder = "${workspace}"; + std::string::size_type pos = 0; + while((pos = value.find(placeholder, pos)) != std::string::npos) { + value.replace(pos, placeholder.size(), workspace_root); + pos += workspace_root.size(); + } +} + +void CliceConfig::apply_defaults(const std::string& workspace_root) { + auto cpu_count = std::thread::hardware_concurrency(); + if(cpu_count == 0) + cpu_count = 4; + + if(stateful_worker_count == 0) { + stateful_worker_count = std::max(1u, cpu_count / 4); + } + if(stateless_worker_count == 0) { + stateless_worker_count = std::max(1u, cpu_count / 4); + } + if(worker_memory_limit == 0) { + worker_memory_limit = 4ULL * 1024 * 1024 * 1024; // 4GB default + } + if(cache_dir.empty() && !workspace_root.empty()) { + cache_dir = path::join(workspace_root, ".clice"); + } + + // Apply variable substitution to string fields + substitute_workspace(compile_commands_path, workspace_root); + substitute_workspace(cache_dir, workspace_root); +} + +std::optional CliceConfig::load(const std::string& path, + const std::string& workspace_root) { + auto content = fs::read(path); + if(!content) { + return std::nullopt; + } + + auto result = eventide::serde::toml::parse(*content); + if(!result) { + LOG_WARN("Failed to parse config file {}", path); + return std::nullopt; + } + + auto config = std::move(*result); + config.apply_defaults(workspace_root); + + LOG_INFO("Loaded config from {}", path); + return config; +} + +CliceConfig CliceConfig::load_from_workspace(const std::string& workspace_root) { + if(!workspace_root.empty()) { + // Try standard config file locations + for(auto* name: {"clice.toml", ".clice/config.toml"}) { + auto config_path = path::join(workspace_root, name); + if(llvm::sys::fs::exists(config_path)) { + auto config = load(config_path, workspace_root); + if(config) + return std::move(*config); + } + } + } + + // No config file found; use defaults + CliceConfig config; + config.apply_defaults(workspace_root); + LOG_INFO( + "No clice.toml found, using default configuration " "(stateful={}, stateless={}, memory_limit={}MB)", + config.stateful_worker_count, + config.stateless_worker_count, + config.worker_memory_limit / (1024 * 1024)); + return config; +} + +} // namespace clice diff --git a/src/server/config.h b/src/server/config.h new file mode 100644 index 00000000..087c351f --- /dev/null +++ b/src/server/config.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +namespace clice { + +/// Configuration for the clice LSP server, loadable from clice.toml. +struct CliceConfig { + // Worker configuration (0 = auto-detect from system resources) + std::uint32_t stateful_worker_count = 0; + std::uint32_t stateless_worker_count = 0; + std::uint64_t worker_memory_limit = 0; // bytes; 0 = auto + + // Compilation database path (empty = auto-detect) + std::string compile_commands_path; + + // Cache directory (empty = default: /.clice/) + std::string cache_dir; + + // Debounce interval for re-compilation after edits (milliseconds) + int debounce_ms = 200; + + // Background indexing + bool enable_indexing = true; + int idle_timeout_ms = 3000; + + /// Compute default values for any field left at its zero/empty sentinel. + void apply_defaults(const std::string& workspace_root); + + /// Try to load configuration from a TOML file. + /// Performs ${workspace} variable substitution in string fields. + /// Returns std::nullopt if the file does not exist or cannot be parsed. + static std::optional load(const std::string& path, + const std::string& workspace_root); + + /// Load config from the workspace, trying standard locations. + /// Returns a default config (with apply_defaults) if no file is found. + static CliceConfig load_from_workspace(const std::string& workspace_root); +}; + +} // namespace clice diff --git a/src/server/master_server.cpp b/src/server/master_server.cpp new file mode 100644 index 00000000..d0304562 --- /dev/null +++ b/src/server/master_server.cpp @@ -0,0 +1,586 @@ +#include "server/master_server.h" + +#include +#include +#include +#include + +#include "eventide/ipc/lsp/position.h" +#include "eventide/ipc/lsp/uri.h" +#include "eventide/reflection/enum.h" +#include "eventide/serde/json/json.h" +#include "eventide/serde/serde/raw_value.h" +#include "semantic/symbol_kind.h" +#include "server/protocol.h" +#include "support/filesystem.h" +#include "support/logging.h" + +namespace clice { + +namespace protocol = eventide::ipc::protocol; +namespace lsp = eventide::ipc::lsp; +namespace refl = eventide::refl; +using et::ipc::RequestResult; +using RequestContext = et::ipc::JsonPeer::RequestContext; + +MasterServer::MasterServer(et::event_loop& loop, et::ipc::JsonPeer& peer, std::string self_path) : + loop(loop), peer(peer), pool(loop), self_path(std::move(self_path)) {} + +std::string MasterServer::uri_to_path(const std::string& uri) { + auto parsed = lsp::URI::parse(uri); + if(parsed.has_value()) { + auto path = parsed->file_path(); + if(path.has_value()) { + return std::move(*path); + } + } + return uri; +} + +void MasterServer::publish_diagnostics(const std::string& uri, + int version, + const et::serde::RawValue& diagnostics_json) { + std::vector diagnostics; + if(!diagnostics_json.empty()) { + auto status = et::serde::json::from_json(diagnostics_json.data, diagnostics); + if(!status) { + LOG_WARN("Failed to deserialize diagnostics JSON for {}", uri); + } + } + protocol::PublishDiagnosticsParams params; + params.uri = uri; + params.version = version; + params.diagnostics = std::move(diagnostics); + peer.send_notification(params); +} + +void MasterServer::clear_diagnostics(const std::string& uri) { + protocol::PublishDiagnosticsParams params; + params.uri = uri; + params.diagnostics = {}; + peer.send_notification(params); +} + +void MasterServer::schedule_build(std::uint32_t path_id, const std::string& uri) { + auto it = documents.find(path_id); + if(it == documents.end()) + return; + + auto& doc = it->second; + + if(doc.build_running) { + doc.build_requested = true; + return; + } + + // Create or reset debounce timer + auto& timer_ptr = debounce_timers[path_id]; + if(!timer_ptr) { + timer_ptr = std::make_unique(et::timer::create(loop)); + } + timer_ptr->start(std::chrono::milliseconds(config.debounce_ms)); + + if(!doc.drain_scheduled) { + doc.drain_scheduled = true; + loop.schedule(run_build_drain(path_id, uri)); + } +} + +et::task<> MasterServer::run_build_drain(std::uint32_t path_id, std::string uri) { + // Wait for debounce timer + auto timer_it = debounce_timers.find(path_id); + if(timer_it != debounce_timers.end() && timer_it->second) { + co_await timer_it->second->wait(); + } + + while(true) { + auto doc_it = documents.find(path_id); + if(doc_it == documents.end()) + co_return; + + doc_it->second.build_running = true; + doc_it->second.build_requested = false; + auto gen = doc_it->second.generation; + + // Send compile request to stateful worker + worker::CompileParams params; + params.path = std::string(path_pool.resolve(path_id)); + params.version = doc_it->second.version; + params.text = doc_it->second.text; + fill_compile_args(path_pool.resolve(path_id), params.directory, params.arguments); + + LOG_DEBUG("Sending compile: path={}, args={}, gen={}", + params.path, + params.arguments.size(), + gen); + + auto result = co_await pool.send_stateful(path_id, params); + + // Re-lookup document (may have been closed during compile) + doc_it = documents.find(path_id); + if(doc_it == documents.end()) + co_return; + + auto& doc2 = doc_it->second; + + if(result.has_value()) { + // Only publish diagnostics if the generation hasn't changed + if(doc2.generation == gen) { + publish_diagnostics(uri, doc2.version, result.value().diagnostics); + } else { + LOG_DEBUG("Generation mismatch ({} vs {}), dropping diagnostics for {}", + doc2.generation, + gen, + uri); + } + } else { + LOG_WARN("Compile failed for {}: {}", uri, result.error().message); + // Publish empty diagnostics so stale errors don't linger + clear_diagnostics(uri); + } + + // Check if more builds were requested while compiling + if(!doc2.build_requested) { + doc2.build_running = false; + doc2.drain_scheduled = false; + co_return; + } + // Loop continues for the next build + } +} + +et::task<> MasterServer::load_workspace() { + if(workspace_root.empty()) + co_return; + + // Create cache directory if configured + if(!config.cache_dir.empty()) { + auto ec = llvm::sys::fs::create_directories(config.cache_dir); + if(ec) { + LOG_WARN("Failed to create cache directory {}: {}", config.cache_dir, ec.message()); + } else { + LOG_INFO("Cache directory: {}", config.cache_dir); + } + } + + // Search for compile_commands.json + std::string cdb_path; + + // If the config specifies a CDB path, use it + if(!config.compile_commands_path.empty()) { + if(llvm::sys::fs::exists(config.compile_commands_path)) { + cdb_path = config.compile_commands_path; + } else { + LOG_WARN("Configured compile_commands_path not found: {}", + config.compile_commands_path); + } + } + + // Otherwise auto-detect in common locations + if(cdb_path.empty()) { + for(auto* subdir: {"build", "cmake-build-debug", "cmake-build-release", "out", "."}) { + auto candidate = path::join(workspace_root, subdir, "compile_commands.json"); + if(llvm::sys::fs::exists(candidate)) { + cdb_path = std::move(candidate); + break; + } + } + } + + if(cdb_path.empty()) { + LOG_WARN("No compile_commands.json found in workspace {}", workspace_root); + co_return; + } + + auto updates = cdb.load_compile_database(cdb_path); + LOG_INFO("Loaded CDB from {} with {} entries", cdb_path, updates.size()); +} + +void MasterServer::fill_compile_args(llvm::StringRef path, + std::string& directory, + std::vector& arguments) { + auto ctx = cdb.lookup(path, {.resource_dir = true, .query_toolchain = true}); + directory = ctx.directory.str(); + arguments.clear(); + for(auto* arg: ctx.arguments) { + arguments.emplace_back(arg); + } +} + +et::task MasterServer::ensure_compiled(std::uint32_t path_id, const std::string& uri) { + auto doc_it = documents.find(path_id); + if(doc_it == documents.end()) + co_return false; + + // If the document has never been compiled, schedule a build and wait + // For now, just return true - the worker may already have an AST + // from a previous compile, or the feature request will return empty results. + co_return true; +} + +// ========================================================================= +// Forwarding helpers +// ========================================================================= + +using serde_raw = et::serde::RawValue; + +template +MasterServer::RawResult MasterServer::forward_stateful(const std::string& uri) { + auto path = uri_to_path(uri); + auto path_id = path_pool.intern(path); + + if(!co_await ensure_compiled(path_id, uri)) + co_return serde_raw{"null"}; + + WorkerParams wp; + wp.path = path; + + auto result = co_await pool.send_stateful(path_id, wp); + if(!result.has_value()) + co_return serde_raw{}; + co_return std::move(result.value()); +} + +template +MasterServer::RawResult MasterServer::forward_stateful(const std::string& uri, + const protocol::Position& position) { + auto path = uri_to_path(uri); + auto path_id = path_pool.intern(path); + + if(!co_await ensure_compiled(path_id, uri)) + co_return serde_raw{"null"}; + + WorkerParams wp; + wp.path = path; + + auto doc_it = documents.find(path_id); + if(doc_it != documents.end()) { + lsp::PositionMapper mapper(doc_it->second.text, lsp::PositionEncoding::UTF16); + wp.offset = mapper.to_offset(position); + } + + auto result = co_await pool.send_stateful(path_id, wp); + if(!result.has_value()) + co_return serde_raw{}; + co_return std::move(result.value()); +} + +template +MasterServer::RawResult MasterServer::forward_stateless(const std::string& uri, + const protocol::Position& position) { + auto path = uri_to_path(uri); + auto path_id = path_pool.intern(path); + + auto doc_it = documents.find(path_id); + if(doc_it == documents.end()) + co_return serde_raw{}; + + auto& doc = doc_it->second; + + lsp::PositionMapper mapper(doc.text, lsp::PositionEncoding::UTF16); + + WorkerParams wp; + wp.path = path; + wp.version = doc.version; + wp.text = doc.text; + fill_compile_args(path, wp.directory, wp.arguments); + wp.offset = mapper.to_offset(position); + + auto result = co_await pool.send_stateless(wp); + if(!result.has_value()) + co_return serde_raw{}; + co_return std::move(result.value()); +} + +void MasterServer::register_handlers() { + // === initialize === + peer.on_request([this](RequestContext& ctx, const protocol::InitializeParams& params) + -> RequestResult { + if(lifecycle != ServerLifecycle::Uninitialized) { + co_return et::outcome_error(protocol::Error{"Server already initialized"}); + } + + // Extract workspace root + auto& init = params.lsp__initialize_params; + if(init.root_uri.has_value()) { + workspace_root = uri_to_path(*init.root_uri); + } + + lifecycle = ServerLifecycle::Initialized; + + LOG_INFO("Initialized with workspace: {}", workspace_root); + + // Build capabilities + protocol::InitializeResult result; + + // Text document sync: incremental + protocol::TextDocumentSyncOptions sync_opts; + sync_opts.open_close = true; + sync_opts.change = protocol::TextDocumentSyncKind::Incremental; + sync_opts.save = protocol::variant{true}; + result.capabilities.text_document_sync = std::move(sync_opts); + + // Feature capabilities + result.capabilities.hover_provider = true; + result.capabilities.completion_provider = protocol::CompletionOptions{}; + result.capabilities.signature_help_provider = protocol::SignatureHelpOptions{}; + result.capabilities.definition_provider = true; + result.capabilities.document_symbol_provider = true; + result.capabilities.document_link_provider = protocol::DocumentLinkOptions{}; + result.capabilities.code_action_provider = true; + result.capabilities.folding_range_provider = true; + result.capabilities.inlay_hint_provider = true; + + // Semantic tokens + protocol::SemanticTokensOptions sem_opts; + { + auto lower_first = [](std::string_view name) -> std::string { + std::string s(name); + if(!s.empty()) { + s[0] = static_cast(std::tolower(static_cast(s[0]))); + } + return s; + }; + + auto to_names = [&](auto names) { + return std::ranges::to(names | std::views::transform(lower_first)); + }; + + sem_opts.legend = protocol::SemanticTokensLegend{ + to_names(refl::reflection::member_names), + to_names(refl::reflection::member_names), + }; + } + sem_opts.full = true; + result.capabilities.semantic_tokens_provider = std::move(sem_opts); + + // Server info + protocol::ServerInfo info; + info.name = "clice"; + info.version = "0.1.0"; + result.server_info = std::move(info); + + co_return result; + }); + + // === initialized === + peer.on_notification([this](const protocol::InitializedParams& params) { + // Load configuration from workspace + config = CliceConfig::load_from_workspace(workspace_root); + + LOG_INFO("Server ready (stateful={}, stateless={}, debounce={}ms, idle={}ms)", + config.stateful_worker_count, + config.stateless_worker_count, + config.debounce_ms, + config.idle_timeout_ms); + + // Start worker pool + WorkerPoolOptions pool_opts; + pool_opts.self_path = self_path; + pool_opts.stateful_count = config.stateful_worker_count; + pool_opts.stateless_count = config.stateless_worker_count; + pool_opts.worker_memory_limit = config.worker_memory_limit; + if(!pool.start(pool_opts)) { + LOG_ERROR("Failed to start worker pool"); + return; + } + + lifecycle = ServerLifecycle::Ready; + + // Load CDB in background + loop.schedule(load_workspace()); + }); + + // === shutdown === + peer.on_request( + [this](RequestContext& ctx, + const protocol::ShutdownParams& params) -> RequestResult { + lifecycle = ServerLifecycle::ShuttingDown; + LOG_INFO("Shutdown requested"); + co_return nullptr; + }); + + // === exit === + peer.on_notification([this](const protocol::ExitParams& params) { + lifecycle = ServerLifecycle::Exited; + LOG_INFO("Exit notification received"); + + // Graceful shutdown: cancel compilations, stop workers, then stop loop + loop.schedule([this]() -> et::task<> { + co_await pool.stop(); + loop.stop(); + }()); + }); + + // === textDocument/didOpen === + peer.on_notification([this](const protocol::DidOpenTextDocumentParams& params) { + if(lifecycle != ServerLifecycle::Ready) + return; + + auto& td = params.text_document; + auto path = uri_to_path(td.uri); + auto path_id = path_pool.intern(path); + + auto& doc = documents[path_id]; + doc.version = td.version; + doc.text = td.text; + doc.generation++; + + LOG_DEBUG("didOpen: {} (v{})", path, td.version); + + schedule_build(path_id, td.uri); + }); + + // === textDocument/didChange === + peer.on_notification([this](const protocol::DidChangeTextDocumentParams& params) { + if(lifecycle != ServerLifecycle::Ready) + return; + + auto path = uri_to_path(params.text_document.uri); + auto path_id = path_pool.intern(path); + + auto it = documents.find(path_id); + if(it == documents.end()) + return; + + auto& doc = it->second; + doc.version = params.text_document.version; + + // Apply incremental changes + for(auto& change: params.content_changes) { + std::visit( + [&](auto& c) { + using T = std::remove_cvref_t; + if constexpr(std::is_same_v) { + doc.text = c.text; + } else { + // Incremental change: replace range + auto& range = c.range; + + lsp::PositionMapper mapper(doc.text, lsp::PositionEncoding::UTF16); + auto start = mapper.to_offset(range.start); + auto end = mapper.to_offset(range.end); + if(start <= doc.text.size() && end <= doc.text.size() && start <= end) { + doc.text.replace(start, end - start, c.text); + } + } + }, + change); + } + + doc.generation++; + + // Notify the owning stateful worker so it marks the document dirty + worker::DocumentUpdateParams update; + update.path = path; + update.version = doc.version; + update.text = doc.text; + pool.notify_stateful(path_id, update); + + schedule_build(path_id, params.text_document.uri); + }); + + // === textDocument/didClose === + peer.on_notification([this](const protocol::DidCloseTextDocumentParams& params) { + if(lifecycle != ServerLifecycle::Ready) + return; + + auto path = uri_to_path(params.text_document.uri); + auto path_id = path_pool.intern(path); + + documents.erase(path_id); + debounce_timers.erase(path_id); + + // Clear diagnostics for closed file + clear_diagnostics(params.text_document.uri); + + LOG_DEBUG("didClose: {}", path); + }); + + // === textDocument/didSave === + peer.on_notification([this](const protocol::DidSaveTextDocumentParams& params) { + if(lifecycle != ServerLifecycle::Ready) + return; + + // TODO: Trigger dependent file rebuilds + LOG_DEBUG("didSave: {}", params.text_document.uri); + }); + + // ========================================================================= + // Feature requests routed to stateful workers (RawValue passthrough) + // ========================================================================= + + // --- textDocument/hover --- + peer.on_request([this](RequestContext& ctx, const protocol::HoverParams& params) -> RawResult { + co_return co_await forward_stateful( + params.text_document_position_params.text_document.uri, + params.text_document_position_params.position); + }); + + // --- textDocument/semanticTokens/full --- + peer.on_request([this](RequestContext& ctx, + const protocol::SemanticTokensParams& params) -> RawResult { + co_return co_await forward_stateful(params.text_document.uri); + }); + + // --- textDocument/inlayHint --- + peer.on_request( + [this](RequestContext& ctx, const protocol::InlayHintParams& params) -> RawResult { + co_return co_await forward_stateful(params.text_document.uri); + }); + + // --- textDocument/foldingRange --- + peer.on_request([this](RequestContext& ctx, + const protocol::FoldingRangeParams& params) -> RawResult { + co_return co_await forward_stateful(params.text_document.uri); + }); + + // --- textDocument/documentSymbol --- + peer.on_request([this](RequestContext& ctx, + const protocol::DocumentSymbolParams& params) -> RawResult { + co_return co_await forward_stateful(params.text_document.uri); + }); + + // --- textDocument/documentLink --- + peer.on_request([this](RequestContext& ctx, + const protocol::DocumentLinkParams& params) -> RawResult { + co_return co_await forward_stateful(params.text_document.uri); + }); + + // --- textDocument/codeAction --- + peer.on_request( + [this](RequestContext& ctx, const protocol::CodeActionParams& params) -> RawResult { + co_return co_await forward_stateful(params.text_document.uri); + }); + + // --- textDocument/definition --- + peer.on_request( + [this](RequestContext& ctx, const protocol::DefinitionParams& params) -> RawResult { + co_return co_await forward_stateful( + params.text_document_position_params.text_document.uri, + params.text_document_position_params.position); + }); + + // ========================================================================= + // Feature requests routed to stateless workers + // ========================================================================= + + // --- textDocument/completion --- + peer.on_request( + [this](RequestContext& ctx, const protocol::CompletionParams& params) -> RawResult { + co_return co_await forward_stateless( + params.text_document_position_params.text_document.uri, + params.text_document_position_params.position); + }); + + // --- textDocument/signatureHelp --- + peer.on_request( + [this](RequestContext& ctx, const protocol::SignatureHelpParams& params) -> RawResult { + co_return co_await forward_stateless( + params.text_document_position_params.text_document.uri, + params.text_document_position_params.position); + }); +} + +} // namespace clice diff --git a/src/server/master_server.h b/src/server/master_server.h new file mode 100644 index 00000000..e488a124 --- /dev/null +++ b/src/server/master_server.h @@ -0,0 +1,130 @@ +#pragma once + +#include +#include +#include + +#include "compile/command.h" +#include "eventide/async/async.h" +#include "eventide/ipc/lsp/protocol.h" +#include "eventide/ipc/peer.h" +#include "eventide/serde/serde/raw_value.h" +#include "server/config.h" +#include "server/worker_pool.h" + +#include "llvm/ADT/DenseMap.h" +#include "llvm/ADT/SmallVector.h" +#include "llvm/ADT/StringMap.h" +#include "llvm/ADT/StringRef.h" +#include "llvm/Support/Allocator.h" + +namespace clice { + +namespace et = eventide; +namespace protocol = et::ipc::protocol; + +/// Global path interning pool. Maps file paths to uint32_t IDs. +struct ServerPathPool { + llvm::BumpPtrAllocator allocator; + llvm::SmallVector paths; + llvm::StringMap cache; + + std::uint32_t intern(llvm::StringRef path) { + auto [it, inserted] = cache.try_emplace(path, paths.size()); + if(inserted) { + auto saved = path.copy(allocator); + paths.push_back(saved); + } + return it->second; + } + + llvm::StringRef resolve(std::uint32_t id) const { + return paths[id]; + } +}; + +struct DocumentState { + int version = 0; + std::string text; + std::uint64_t generation = 0; + bool build_running = false; + bool build_requested = false; + bool drain_scheduled = false; +}; + +enum class ServerLifecycle : std::uint8_t { + Uninitialized, + Initialized, + Ready, + ShuttingDown, + Exited, +}; + +class MasterServer { +public: + MasterServer(et::event_loop& loop, et::ipc::JsonPeer& peer, std::string self_path); + + void register_handlers(); + +private: + et::event_loop& loop; + et::ipc::JsonPeer& peer; + WorkerPool pool; + ServerPathPool path_pool; + ServerLifecycle lifecycle = ServerLifecycle::Uninitialized; + + std::string self_path; + std::string workspace_root; + CliceConfig config; + + CompilationDatabase cdb; + + // Document state: path_id -> DocumentState + llvm::DenseMap documents; + + // Per-document debounce timers + llvm::DenseMap> debounce_timers; + + // Helper: convert URI to file path + std::string uri_to_path(const std::string& uri); + + // Publish diagnostics to client + void publish_diagnostics(const std::string& uri, + int version, + const eventide::serde::RawValue& diagnostics_json); + void clear_diagnostics(const std::string& uri); + + // Schedule a build after debounce + void schedule_build(std::uint32_t path_id, const std::string& uri); + + // Build drain coroutine: waits for debounce, then runs compile loop + et::task<> run_build_drain(std::uint32_t path_id, std::string uri); + + // Ensure a file has been compiled before servicing feature requests + et::task ensure_compiled(std::uint32_t path_id, const std::string& uri); + + // Load CDB and build initial include graph + et::task<> load_workspace(); + + // Helper: fill compile arguments from CDB into worker params + void fill_compile_args(llvm::StringRef path, + std::string& directory, + std::vector& arguments); + + // Forwarding helpers for feature requests (RawValue passthrough) + using RawResult = et::task; + + /// Forward a simple stateful request (path-only worker params). + template + RawResult forward_stateful(const std::string& uri); + + /// Forward a stateful request with position-to-offset conversion. + template + RawResult forward_stateful(const std::string& uri, const protocol::Position& position); + + /// Forward a stateless request with document content and compile args. + template + RawResult forward_stateless(const std::string& uri, const protocol::Position& position); +}; + +} // namespace clice diff --git a/src/server/protocol.h b/src/server/protocol.h new file mode 100644 index 00000000..03e6a2b8 --- /dev/null +++ b/src/server/protocol.h @@ -0,0 +1,258 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "eventide/ipc/lsp/protocol.h" +#include "eventide/ipc/protocol.h" +#include "eventide/serde/serde/raw_value.h" + +namespace clice::worker { + +namespace protocol = eventide::ipc::protocol; + +// === StatefulWorker Requests === + +struct CompileParams { + std::string path; + int version; + std::string text; + std::string directory; + std::vector arguments; + std::pair pch; + std::unordered_map pcms; +}; + +struct CompileResult { + int version; + /// Diagnostics serialized as JSON (RawValue) to avoid bincode/serde annotation conflicts. + eventide::serde::RawValue diagnostics; + std::size_t memory_usage; +}; + +struct HoverParams { + std::string path; + uint32_t offset; +}; + +struct SemanticTokensParams { + std::string path; +}; + +struct InlayHintsParams { + std::string path; +}; + +struct FoldingRangeParams { + std::string path; +}; + +struct DocumentSymbolParams { + std::string path; +}; + +struct DocumentLinkParams { + std::string path; +}; + +struct CodeActionParams { + std::string path; +}; + +struct GoToDefinitionParams { + std::string path; + uint32_t offset; +}; + +// === StatelessWorker Requests === + +struct CompletionParams { + std::string path; + int version; + std::string text; + std::string directory; + std::vector arguments; + std::pair pch; + std::unordered_map pcms; + uint32_t offset; +}; + +struct SignatureHelpParams { + std::string path; + int version; + std::string text; + std::string directory; + std::vector arguments; + std::pair pch; + std::unordered_map pcms; + uint32_t offset; +}; + +struct BuildPCHParams { + std::string file; + std::string directory; + std::vector arguments; + std::string content; +}; + +struct BuildPCHResult { + bool success; + std::string error; +}; + +struct BuildPCMParams { + std::string file; + std::string directory; + std::vector arguments; + std::string module_name; + std::unordered_map pcms; +}; + +struct BuildPCMResult { + bool success; + std::string error; +}; + +struct IndexParams { + std::string file; + std::string directory; + std::vector arguments; + std::unordered_map pcms; +}; + +struct IndexResult { + bool success; + std::string error; + std::string tu_index_data; +}; + +// === Notifications === + +struct DocumentUpdateParams { + std::string path; + int version; + std::string text; +}; + +struct EvictParams { + std::string path; +}; + +struct EvictedParams { + std::string path; +}; + +} // namespace clice::worker + +namespace eventide::ipc::protocol { + +// === Stateful Requests === + +template <> +struct RequestTraits { + using Result = clice::worker::CompileResult; + constexpr inline static std::string_view method = "clice/worker/compile"; +}; + +template <> +struct RequestTraits { + using Result = eventide::serde::RawValue; + constexpr inline static std::string_view method = "clice/worker/hover"; +}; + +template <> +struct RequestTraits { + using Result = eventide::serde::RawValue; + constexpr inline static std::string_view method = "clice/worker/semanticTokens"; +}; + +template <> +struct RequestTraits { + using Result = eventide::serde::RawValue; + constexpr inline static std::string_view method = "clice/worker/inlayHints"; +}; + +template <> +struct RequestTraits { + using Result = eventide::serde::RawValue; + constexpr inline static std::string_view method = "clice/worker/foldingRange"; +}; + +template <> +struct RequestTraits { + using Result = eventide::serde::RawValue; + constexpr inline static std::string_view method = "clice/worker/documentSymbol"; +}; + +template <> +struct RequestTraits { + using Result = eventide::serde::RawValue; + constexpr inline static std::string_view method = "clice/worker/documentLink"; +}; + +template <> +struct RequestTraits { + using Result = eventide::serde::RawValue; + constexpr inline static std::string_view method = "clice/worker/codeAction"; +}; + +template <> +struct RequestTraits { + using Result = eventide::serde::RawValue; + constexpr inline static std::string_view method = "clice/worker/goToDefinition"; +}; + +// === Stateless Requests === + +template <> +struct RequestTraits { + using Result = eventide::serde::RawValue; + constexpr inline static std::string_view method = "clice/worker/completion"; +}; + +template <> +struct RequestTraits { + using Result = eventide::serde::RawValue; + constexpr inline static std::string_view method = "clice/worker/signatureHelp"; +}; + +template <> +struct RequestTraits { + using Result = clice::worker::BuildPCHResult; + constexpr inline static std::string_view method = "clice/worker/buildPCH"; +}; + +template <> +struct RequestTraits { + using Result = clice::worker::BuildPCMResult; + constexpr inline static std::string_view method = "clice/worker/buildPCM"; +}; + +template <> +struct RequestTraits { + using Result = clice::worker::IndexResult; + constexpr inline static std::string_view method = "clice/worker/index"; +}; + +// === Notifications === + +template <> +struct NotificationTraits { + constexpr inline static std::string_view method = "clice/worker/documentUpdate"; +}; + +template <> +struct NotificationTraits { + constexpr inline static std::string_view method = "clice/worker/evict"; +}; + +template <> +struct NotificationTraits { + constexpr inline static std::string_view method = "clice/worker/evicted"; +}; + +} // namespace eventide::ipc::protocol diff --git a/src/server/stateful_worker.cpp b/src/server/stateful_worker.cpp new file mode 100644 index 00000000..6b8d4720 --- /dev/null +++ b/src/server/stateful_worker.cpp @@ -0,0 +1,340 @@ +#include "server/stateful_worker.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "compile/compilation.h" +#include "eventide/async/async.h" +#include "eventide/ipc/json_codec.h" +#include "eventide/ipc/peer.h" +#include "eventide/ipc/transport.h" +#include "eventide/serde/json/serializer.h" +#include "eventide/serde/serde/raw_value.h" +#include "feature/feature.h" +#include "server/protocol.h" +#include "support/logging.h" + +#include "llvm/ADT/StringMap.h" + +namespace clice { + +namespace et = eventide; +using et::ipc::RequestResult; +using RequestContext = et::ipc::BincodePeer::RequestContext; + +struct DocumentEntry { + int version = 0; + std::string text; + bool has_ast = false; + CompilationUnit unit{nullptr}; + std::atomic dirty{false}; + + // Signaled when the first compilation completes (has_ast becomes true). + // Feature handlers co_await this before accessing the AST. + et::event ast_ready{false}; + + // Compilation context (from CompileParams) + std::string directory; + std::vector arguments; + std::pair pch; + llvm::StringMap pcms; + + // Per-document serialization mutex + et::mutex strand; +}; + +struct ScopedTimer { + std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); + + long long ms() const { + return std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + } +}; + +static void fill_args(CompilationParams& cp, + const std::string& directory, + const std::vector& arguments) { + cp.directory = directory; + for(auto& arg: arguments) { + cp.arguments.push_back(arg.c_str()); + } +} + +/// Serialize any value to LSP JSON RawValue. +template +static et::serde::RawValue to_raw(const T& value) { + auto json = et::serde::json::to_json(value); + return et::serde::RawValue{json ? std::move(*json) : "null"}; +} + +class StatefulWorker { + et::ipc::BincodePeer& peer; + std::uint64_t memory_limit; + + llvm::StringMap> documents; + + // LRU tracking — owns keys so they don't dangle after request handler returns + std::list lru; + llvm::StringMap::iterator> lru_index; + + void touch_lru(llvm::StringRef path) { + auto it = lru_index.find(path); + if(it != lru_index.end()) { + lru.erase(it->second); + } + lru.emplace_front(path.str()); + lru_index[path] = lru.begin(); + } + + void shrink_if_over_limit() { + // TODO: Implement memory-based eviction using memory_limit. + // For now, cap at a fixed number of documents. + while(documents.size() > 16 && !lru.empty()) { + auto path = lru.back(); + lru.pop_back(); + lru_index.erase(path); + LOG_DEBUG("Evicting document: {}", path); + peer.send_notification(worker::EvictedParams{std::string(path)}); + documents.erase(path); + } + } + + DocumentEntry& get_or_create(llvm::StringRef path) { + auto [it, inserted] = documents.try_emplace(path, nullptr); + if(inserted) { + it->second = std::make_unique(); + LOG_DEBUG("Created new document entry: {}", path.str()); + } + return *it->second; + } + + /// Look up document, wait for AST, lock strand, run fn(doc) on thread pool, unlock. + /// Returns "null" if document not found or AST not usable. + template + et::task with_ast(llvm::StringRef path, F&& fn) { + auto it = documents.find(path); + if(it == documents.end()) + co_return et::serde::RawValue{"null"}; + + auto& doc = *it->second; + touch_lru(path); + + co_await doc.ast_ready.wait(); + co_await doc.strand.lock(); + + auto result = co_await et::queue([&]() -> et::serde::RawValue { + if(!doc.has_ast || (!doc.unit.completed() && !doc.unit.fatal_error())) + return et::serde::RawValue{"null"}; + return fn(doc); + }); + + doc.strand.unlock(); + co_return result.value(); + } + +public: + StatefulWorker(et::ipc::BincodePeer& peer, std::uint64_t memory_limit) : + peer(peer), memory_limit(memory_limit) {} + + void register_handlers(); +}; + +void StatefulWorker::register_handlers() { + // === Compile === + peer.on_request( + [this](RequestContext& ctx, + const worker::CompileParams& params) -> RequestResult { + LOG_INFO("Compile request: path={}, version={}", params.path, params.version); + + auto& doc = get_or_create(params.path); + doc.version = params.version; + doc.text = params.text; + doc.directory = params.directory; + doc.arguments = params.arguments; + doc.pch = params.pch; + doc.pcms.clear(); + for(auto& [name, pcm_path]: params.pcms) { + doc.pcms.try_emplace(name, pcm_path); + } + + touch_lru(params.path); + + co_await doc.strand.lock(); + + auto compile_result = co_await et::queue([&]() -> worker::CompileResult { + LOG_DEBUG("Compiling: path={}, {} args", params.path, doc.arguments.size()); + + ScopedTimer timer; + + CompilationParams cp; + cp.kind = CompilationKind::Content; + fill_args(cp, doc.directory, doc.arguments); + if(!doc.pch.first.empty()) { + cp.pch = doc.pch; + } + cp.add_remapped_file(params.path, doc.text); + for(auto& entry: doc.pcms) { + cp.pcms.try_emplace(entry.getKey(), entry.getValue()); + } + + doc.unit = compile(cp); + doc.has_ast = true; + doc.dirty.store(false, std::memory_order_release); + + worker::CompileResult result; + result.version = doc.version; + if(doc.unit.completed() || doc.unit.fatal_error()) { + auto diags = feature::diagnostics(doc.unit); + auto json = et::serde::json::to_json(diags); + result.diagnostics = et::serde::RawValue{json ? std::move(*json) : "[]"}; + LOG_INFO("Compile done: path={}, {}ms, {} diags, fatal={}", + params.path, + timer.ms(), + diags.size(), + doc.unit.fatal_error()); + } else { + result.diagnostics = et::serde::RawValue{"[]"}; + LOG_WARN("Compile incomplete: path={}, {}ms", params.path, timer.ms()); + } + result.memory_usage = 0; // TODO: query actual memory + return result; + }); + + doc.strand.unlock(); + doc.ast_ready.set(); + shrink_if_over_limit(); + + co_return compile_result.value(); + }); + + // === DocumentUpdate === + peer.on_notification([this](const worker::DocumentUpdateParams& params) { + LOG_TRACE("DocumentUpdate: path={}, version={}", params.path, params.version); + + auto it = documents.find(params.path); + if(it == documents.end()) { + LOG_TRACE("DocumentUpdate ignored (not tracked): path={}", params.path); + return; + } + + auto& doc = *it->second; + doc.version = params.version; + doc.text = params.text; + doc.dirty.store(true, std::memory_order_release); + }); + + // === Evict === + peer.on_notification([this](const worker::EvictParams& params) { + LOG_DEBUG("Evict notification: path={}", params.path); + + auto it = lru_index.find(params.path); + if(it != lru_index.end()) { + lru.erase(it->second); + lru_index.erase(it); + } + documents.erase(params.path); + }); + + // === Hover === + peer.on_request( + [this](RequestContext& ctx, + const worker::HoverParams& params) -> RequestResult { + co_return co_await with_ast(params.path, [&](DocumentEntry& doc) { + auto result = feature::hover(doc.unit, params.offset); + return result ? to_raw(*result) : et::serde::RawValue{"null"}; + }); + }); + + // === SemanticTokens === + peer.on_request([this](RequestContext& ctx, const worker::SemanticTokensParams& params) + -> RequestResult { + co_return co_await with_ast(params.path, [&](DocumentEntry& doc) { + return to_raw(feature::semantic_tokens(doc.unit)); + }); + }); + + // === InlayHints === + peer.on_request( + [this](RequestContext& ctx, + const worker::InlayHintsParams& params) -> RequestResult { + co_return co_await with_ast(params.path, [&](DocumentEntry& doc) { + LocalSourceRange range{0, static_cast(doc.text.size())}; + return to_raw(feature::inlay_hints(doc.unit, range)); + }); + }); + + // === FoldingRange === + peer.on_request([this](RequestContext& ctx, const worker::FoldingRangeParams& params) + -> RequestResult { + co_return co_await with_ast(params.path, [&](DocumentEntry& doc) { + return to_raw(feature::folding_ranges(doc.unit)); + }); + }); + + // === DocumentSymbol === + peer.on_request([this](RequestContext& ctx, const worker::DocumentSymbolParams& params) + -> RequestResult { + co_return co_await with_ast(params.path, [&](DocumentEntry& doc) { + return to_raw(feature::document_symbols(doc.unit)); + }); + }); + + // === DocumentLink === + peer.on_request([this](RequestContext& ctx, const worker::DocumentLinkParams& params) + -> RequestResult { + co_return co_await with_ast(params.path, [&](DocumentEntry& doc) { + return to_raw(feature::document_links(doc.unit)); + }); + }); + + // === CodeAction === + peer.on_request( + [this](RequestContext& ctx, + const worker::CodeActionParams& params) -> RequestResult { + LOG_TRACE("CodeAction request: path={}", params.path); + // TODO: Implement code actions + co_return et::serde::RawValue{"[]"}; + }); + + // === GoToDefinition === + peer.on_request([this](RequestContext& ctx, const worker::GoToDefinitionParams& params) + -> RequestResult { + LOG_TRACE("GoToDefinition request: path={}, offset={}", params.path, params.offset); + // TODO: Implement go-to-definition + co_return et::serde::RawValue{"[]"}; + }); +} + +int run_stateful_worker_mode(std::uint64_t memory_limit) { + logging::stderr_logger("stateful-worker", logging::options); + + LOG_INFO("Starting stateful worker, memory_limit={}MB", memory_limit / (1024 * 1024)); + + et::event_loop loop; + + auto transport_result = et::ipc::StreamTransport::open_stdio(loop); + if(!transport_result) { + LOG_ERROR("Failed to open stdio transport"); + return 1; + } + + et::ipc::BincodePeer peer(loop, std::move(*transport_result)); + + StatefulWorker worker(peer, memory_limit); + worker.register_handlers(); + + LOG_INFO("Stateful worker ready, waiting for requests"); + loop.schedule(peer.run()); + auto ret = loop.run(); + LOG_INFO("Stateful worker exiting with code {}", ret); + return ret; +} + +} // namespace clice diff --git a/src/server/stateful_worker.h b/src/server/stateful_worker.h new file mode 100644 index 00000000..b5b9c7d4 --- /dev/null +++ b/src/server/stateful_worker.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace clice { + +/// Run the stateful worker process mode. +/// The worker holds compiled ASTs and handles feature requests +/// (hover, semantic tokens, etc.) alongside compile requests. +int run_stateful_worker_mode(std::uint64_t memory_limit); + +} // namespace clice diff --git a/src/server/stateless_worker.cpp b/src/server/stateless_worker.cpp new file mode 100644 index 00000000..1cfe47b2 --- /dev/null +++ b/src/server/stateless_worker.cpp @@ -0,0 +1,225 @@ +#include "server/stateless_worker.h" + +#include + +#include "compile/compilation.h" +#include "eventide/async/async.h" +#include "eventide/ipc/json_codec.h" +#include "eventide/ipc/peer.h" +#include "eventide/ipc/transport.h" +#include "eventide/serde/json/serializer.h" +#include "eventide/serde/serde/raw_value.h" +#include "feature/feature.h" +#include "server/protocol.h" +#include "support/logging.h" + +namespace clice { + +namespace et = eventide; +using et::ipc::RequestResult; +using RequestContext = et::ipc::BincodePeer::RequestContext; + +struct ScopedTimer { + std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); + + long long ms() const { + return std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + } +}; + +static void fill_args(CompilationParams& cp, + const std::string& directory, + const std::vector& arguments) { + cp.directory = directory; + for(auto& arg: arguments) { + cp.arguments.push_back(arg.c_str()); + } +} + +template +static et::serde::RawValue to_raw(const T& value) { + auto json = et::serde::json::to_json(value); + return et::serde::RawValue{json ? std::move(*json) : "null"}; +} + +int run_stateless_worker_mode() { + logging::stderr_logger("stateless-worker", logging::options); + + LOG_INFO("Starting stateless worker"); + + et::event_loop loop; + + auto transport_result = et::ipc::StreamTransport::open_stdio(loop); + if(!transport_result) { + LOG_ERROR("Failed to open stdio transport"); + return 1; + } + + et::ipc::BincodePeer peer(loop, std::move(*transport_result)); + + // === BuildPCH === + peer.on_request( + [&](RequestContext& ctx, + const worker::BuildPCHParams& params) -> RequestResult { + LOG_INFO("BuildPCH request: file={}", params.file); + + auto result = co_await et::queue([&]() -> worker::BuildPCHResult { + ScopedTimer timer; + + CompilationParams cp; + cp.kind = CompilationKind::Preamble; + fill_args(cp, params.directory, params.arguments); + cp.add_remapped_file(params.file, params.content); + + auto tmp = fs::createTemporaryFile("clice-pch", "pch"); + if(!tmp) { + LOG_ERROR("BuildPCH: failed to create temp file"); + return {false, "Failed to create temporary PCH file"}; + } + cp.output_file = *tmp; + + PCHInfo pch_info; + auto unit = compile(cp, pch_info); + + if(unit.completed()) { + LOG_INFO("BuildPCH done: file={}, {}ms", params.file, timer.ms()); + return {true, ""}; + } else { + LOG_WARN("BuildPCH failed: file={}, {}ms", params.file, timer.ms()); + return {false, "PCH compilation failed"}; + } + }); + co_return result.value(); + }); + + // === BuildPCM === + peer.on_request( + [&](RequestContext& ctx, + const worker::BuildPCMParams& params) -> RequestResult { + LOG_INFO("BuildPCM request: file={}, module={}", params.file, params.module_name); + + auto result = co_await et::queue([&]() -> worker::BuildPCMResult { + ScopedTimer timer; + + CompilationParams cp; + cp.kind = CompilationKind::ModuleInterface; + fill_args(cp, params.directory, params.arguments); + for(auto& [name, path]: params.pcms) { + cp.pcms.try_emplace(name, path); + } + + auto tmp = fs::createTemporaryFile("clice-pcm", "pcm"); + if(!tmp) { + LOG_ERROR("BuildPCM: failed to create temp file"); + return {false, "Failed to create temporary PCM file"}; + } + cp.output_file = *tmp; + + PCMInfo pcm_info; + auto unit = compile(cp, pcm_info); + + if(unit.completed()) { + LOG_INFO("BuildPCM done: module={}, {}ms", params.module_name, timer.ms()); + return {true, ""}; + } else { + LOG_WARN("BuildPCM failed: module={}, {}ms", params.module_name, timer.ms()); + return {false, "PCM compilation failed"}; + } + }); + co_return result.value(); + }); + + // === Completion === + peer.on_request( + [&](RequestContext& ctx, + const worker::CompletionParams& params) -> RequestResult { + LOG_DEBUG("Completion request: path={}, offset={}", params.path, params.offset); + + auto result = co_await et::queue([&]() -> et::serde::RawValue { + ScopedTimer timer; + + CompilationParams cp; + cp.kind = CompilationKind::Completion; + fill_args(cp, params.directory, params.arguments); + if(!params.pch.first.empty()) { + cp.pch = params.pch; + } + for(auto& [name, path]: params.pcms) { + cp.pcms.try_emplace(name, path); + } + cp.add_remapped_file(params.path, params.text); + cp.completion = {params.path, params.offset}; + + auto items = feature::code_complete(cp); + LOG_DEBUG("Completion done: {} items, {}ms", items.size(), timer.ms()); + return to_raw(items); + }); + co_return result.value(); + }); + + // === SignatureHelp === + peer.on_request([&](RequestContext& ctx, const worker::SignatureHelpParams& params) + -> RequestResult { + LOG_DEBUG("SignatureHelp request: path={}, offset={}", params.path, params.offset); + + auto result = co_await et::queue([&]() -> et::serde::RawValue { + ScopedTimer timer; + + CompilationParams cp; + cp.kind = CompilationKind::Completion; + fill_args(cp, params.directory, params.arguments); + if(!params.pch.first.empty()) { + cp.pch = params.pch; + } + for(auto& [name, path]: params.pcms) { + cp.pcms.try_emplace(name, path); + } + cp.add_remapped_file(params.path, params.text); + cp.completion = {params.path, params.offset}; + + auto help = feature::signature_help(cp); + LOG_DEBUG("SignatureHelp done: {}ms", timer.ms()); + return to_raw(help); + }); + co_return result.value(); + }); + + // === Index === + peer.on_request([&](RequestContext& ctx, + const worker::IndexParams& params) -> RequestResult { + LOG_INFO("Index request: file={}", params.file); + + auto result = co_await et::queue([&]() -> worker::IndexResult { + ScopedTimer timer; + + CompilationParams cp; + cp.kind = CompilationKind::Indexing; + fill_args(cp, params.directory, params.arguments); + for(auto& [name, path]: params.pcms) { + cp.pcms.try_emplace(name, path); + } + + auto unit = compile(cp); + + if(!unit.completed()) { + LOG_WARN("Index failed: file={}, {}ms", params.file, timer.ms()); + return {false, "Index compilation failed", ""}; + } + + LOG_INFO("Index done: file={}, {}ms", params.file, timer.ms()); + // TODO: Generate TUIndex from the compilation unit + return {true, "", ""}; + }); + co_return result.value(); + }); + + LOG_INFO("Stateless worker ready, waiting for requests"); + loop.schedule(peer.run()); + auto ret = loop.run(); + LOG_INFO("Stateless worker exiting with code {}", ret); + return ret; +} + +} // namespace clice diff --git a/src/server/stateless_worker.h b/src/server/stateless_worker.h new file mode 100644 index 00000000..214d930f --- /dev/null +++ b/src/server/stateless_worker.h @@ -0,0 +1,11 @@ +#pragma once + +namespace clice { + +/// Run the stateless worker process mode. +/// The worker receives one-shot compilation tasks (BuildPCH, BuildPCM, +/// Completion, SignatureHelp, Index) via stdin/stdout bincode IPC, +/// executes them on a thread pool, and returns results. +int run_stateless_worker_mode(); + +} // namespace clice diff --git a/src/server/worker_pool.cpp b/src/server/worker_pool.cpp new file mode 100644 index 00000000..8f3a7ce5 --- /dev/null +++ b/src/server/worker_pool.cpp @@ -0,0 +1,228 @@ +#include "server/worker_pool.h" + +#include +#include + +#include "eventide/ipc/transport.h" +#include "support/logging.h" + +namespace clice { + +namespace { + +/// Coroutine that reads lines from a worker's stderr pipe and logs them +/// with a prefix like [SL-0] or [SF-1]. +et::task<> drain_stderr(et::pipe stderr_pipe, std::string prefix) { + std::string buffer; + while(true) { + auto result = co_await stderr_pipe.read(); + if(!result.has_value()) { + // EOF or error — worker has exited + break; + } + auto& chunk = result.value(); + if(chunk.empty()) + break; + + buffer += chunk; + + // Log complete lines + std::size_t pos = 0; + while(true) { + auto nl = buffer.find('\n', pos); + if(nl == std::string::npos) + break; + auto line = buffer.substr(pos, nl - pos); + if(!line.empty()) { + LOG_INFO("{} {}", prefix, line); + } + pos = nl + 1; + } + buffer.erase(0, pos); + } + + // Flush any remaining partial line + if(!buffer.empty()) { + LOG_INFO("{} {}", prefix, buffer); + } +} + +} // namespace + +bool WorkerPool::spawn_worker(const std::string& self_path, + bool stateful, + std::uint64_t memory_limit) { + et::process::options opts; + opts.file = self_path; + if(stateful) { + opts.args = {self_path, + "--mode", + "stateful-worker", + "--worker-memory-limit", + std::to_string(memory_limit)}; + } else { + opts.args = {self_path, "--mode", "stateless-worker"}; + } + opts.streams = { + et::process::stdio::pipe(true, false), // stdin: child reads + et::process::stdio::pipe(false, true), // stdout: child writes + et::process::stdio::pipe(false, true), // stderr: child writes + }; + + auto result = et::process::spawn(opts, loop); + if(!result) { + LOG_ERROR("Failed to spawn {} worker: {}", + stateful ? "stateful" : "stateless", + result.error().message()); + return false; + } + + auto& spawn = *result; + + // StreamTransport: input = child's stdout (parent reads), output = child's stdin (parent + // writes) + auto transport = std::make_unique(std::move(spawn.stdout_pipe), + std::move(spawn.stdin_pipe)); + auto peer = std::make_unique(loop, std::move(transport)); + + auto& workers = stateful ? stateful_workers : stateless_workers; + auto worker_index = workers.size(); + + // Build log prefix: [SF-0] for stateful, [SL-0] for stateless + std::string prefix = + std::string("[") + (stateful ? "SF-" : "SL-") + std::to_string(worker_index) + "]"; + + // Schedule stderr log collection + loop.schedule(drain_stderr(std::move(spawn.stderr_pipe), prefix)); + + workers.push_back(WorkerProcess{ + .proc = std::move(spawn.proc), + .peer = std::move(peer), + .owned_documents = 0, + }); + + auto& w = workers.back(); + loop.schedule(w.peer->run()); + + return true; +} + +bool WorkerPool::start(const WorkerPoolOptions& options) { + for(std::uint32_t i = 0; i < options.stateless_count; ++i) { + if(!spawn_worker(options.self_path, false, 0)) { + return false; + } + } + + for(std::uint32_t i = 0; i < options.stateful_count; ++i) { + if(!spawn_worker(options.self_path, true, options.worker_memory_limit)) { + return false; + } + } + + // Register evicted notification handler for each stateful worker + for(std::size_t i = 0; i < stateful_workers.size(); ++i) { + stateful_workers[i].peer->on_notification([this](const worker::EvictedParams& params) { + if(on_evicted) { + on_evicted(params.path); + } + }); + } + + LOG_INFO("WorkerPool started: {} stateless, {} stateful workers", + stateless_workers.size(), + stateful_workers.size()); + return true; +} + +et::task<> WorkerPool::stop() { + LOG_INFO("WorkerPool stopping..."); + + // Close output pipes to signal workers to exit gracefully + for(auto& w: stateless_workers) { + w.peer->close_output(); + } + for(auto& w: stateful_workers) { + w.peer->close_output(); + } + + // Send SIGTERM to all workers + for(auto& w: stateless_workers) { + w.proc.kill(SIGTERM); + } + for(auto& w: stateful_workers) { + w.proc.kill(SIGTERM); + } + + // Wait for all worker processes to exit + for(auto& w: stateless_workers) { + co_await w.proc.wait(); + } + for(auto& w: stateful_workers) { + co_await w.proc.wait(); + } + + LOG_INFO("WorkerPool stopped"); +} + +std::size_t WorkerPool::assign_worker(std::uint32_t path_id) { + auto it = owner.find(path_id); + if(it != owner.end()) { + // Already assigned; touch LRU + auto lru_it = owner_lru_index.find(path_id); + if(lru_it != owner_lru_index.end()) { + owner_lru.erase(lru_it->second); + } + owner_lru.push_front(path_id); + owner_lru_index[path_id] = owner_lru.begin(); + return it->second; + } + + // New assignment: pick the least-loaded worker + auto selected = pick_least_loaded(); + owner[path_id] = selected; + stateful_workers[selected].owned_documents++; + owner_lru.push_front(path_id); + owner_lru_index[path_id] = owner_lru.begin(); + return selected; +} + +std::size_t WorkerPool::pick_least_loaded() { + std::size_t best = 0; + for(std::size_t i = 1; i < stateful_workers.size(); ++i) { + if(stateful_workers[i].owned_documents < stateful_workers[best].owned_documents) { + best = i; + } + } + return best; +} + +void WorkerPool::remove_owner(std::uint32_t path_id) { + auto it = owner.find(path_id); + if(it == owner.end()) + return; + + auto worker_idx = it->second; + stateful_workers[worker_idx].owned_documents--; + owner.erase(it); + + auto lru_it = owner_lru_index.find(path_id); + if(lru_it != owner_lru_index.end()) { + owner_lru.erase(lru_it->second); + owner_lru_index.erase(lru_it); + } +} + +void WorkerPool::clear_owner(std::size_t worker_index) { + llvm::SmallVector to_remove; + for(auto& [pid, widx]: owner) { + if(widx == worker_index) { + to_remove.push_back(pid); + } + } + for(auto pid: to_remove) { + remove_owner(pid); + } +} + +} // namespace clice diff --git a/src/server/worker_pool.h b/src/server/worker_pool.h new file mode 100644 index 00000000..ebd3e15c --- /dev/null +++ b/src/server/worker_pool.h @@ -0,0 +1,125 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "eventide/async/async.h" +#include "eventide/ipc/peer.h" +#include "server/protocol.h" + +#include "llvm/ADT/DenseMap.h" +#include "llvm/ADT/SmallVector.h" + +namespace clice { + +/// Default timeout for IPC requests to worker processes. +constexpr inline auto kWorkerRequestTimeout = std::chrono::milliseconds(30000); + +namespace et = eventide; +using et::ipc::RequestResult; + +struct WorkerPoolOptions { + std::string self_path; + std::uint32_t stateless_count = 2; + std::uint32_t stateful_count = 2; + std::uint64_t worker_memory_limit = 4ULL * 1024 * 1024 * 1024; // 4GB default +}; + +class WorkerPool { +public: + WorkerPool(et::event_loop& loop) : loop(loop) {} + + /// Spawn all worker processes. Returns false on failure. + bool start(const WorkerPoolOptions& options); + + /// Gracefully stop all workers. + et::task<> stop(); + + /// Send a request to a stateful worker with path_id affinity routing. + template + RequestResult send_stateful(std::uint32_t path_id, + const Params& params, + et::ipc::request_options opts = {}); + + /// Send a request to a stateless worker with round-robin dispatch. + template + RequestResult send_stateless(const Params& params, et::ipc::request_options opts = {}); + + /// Send a notification to the stateful worker owning path_id (if any). + template + void notify_stateful(std::uint32_t path_id, const Params& params); + + /// Remove path_id from ownership tracking (e.g. when the master learns a + /// document was evicted). + void remove_owner(std::uint32_t path_id); + + /// Callback invoked when a stateful worker sends an EvictedParams notification. + /// The master should translate the path to a path_id and call remove_owner(). + std::function on_evicted; + +private: + struct WorkerProcess { + et::process proc; + std::unique_ptr peer; + std::size_t owned_documents = 0; + }; + + et::event_loop& loop; + llvm::SmallVector stateless_workers; + llvm::SmallVector stateful_workers; + std::size_t next_stateless = 0; + + // Stateful worker routing: path_id -> worker index with LRU tracking + llvm::DenseMap owner; + std::list owner_lru; + llvm::DenseMap::iterator> owner_lru_index; + + std::size_t assign_worker(std::uint32_t path_id); + void clear_owner(std::size_t worker_index); + std::size_t pick_least_loaded(); + + bool spawn_worker(const std::string& self_path, bool stateful, std::uint64_t memory_limit); +}; + +// --- Template implementations --------------------------------------------------- + +template +RequestResult WorkerPool::send_stateful(std::uint32_t path_id, + const Params& params, + et::ipc::request_options opts) { + if(stateful_workers.empty()) { + co_return et::outcome_error(et::ipc::Error{"No stateful workers available"}); + } + if(!opts.timeout.has_value()) { + opts.timeout = kWorkerRequestTimeout; + } + auto idx = assign_worker(path_id); + co_return co_await stateful_workers[idx].peer->send_request(params, opts); +} + +template +RequestResult WorkerPool::send_stateless(const Params& params, + et::ipc::request_options opts) { + if(stateless_workers.empty()) { + co_return et::outcome_error(et::ipc::Error{"No stateless workers available"}); + } + if(!opts.timeout.has_value()) { + opts.timeout = kWorkerRequestTimeout; + } + auto idx = next_stateless; + next_stateless = (next_stateless + 1) % stateless_workers.size(); + co_return co_await stateless_workers[idx].peer->send_request(params, opts); +} + +template +void WorkerPool::notify_stateful(std::uint32_t path_id, const Params& params) { + auto it = owner.find(path_id); + if(it == owner.end()) + return; + stateful_workers[it->second].peer->send_notification(params); +} + +} // namespace clice diff --git a/src/syntax/scan.cpp b/src/syntax/scan.cpp index ebfb3e71..33242760 100644 --- a/src/syntax/scan.cpp +++ b/src/syntax/scan.cpp @@ -221,6 +221,8 @@ public: } private: + using DirectiveVec = llvm::SmallVector; + llvm::ArrayRef get_directives(SharedScanCache::CachedEntry& entry) { if(mode == ScanMode::Precise) { @@ -229,11 +231,13 @@ private: // Fuzzy mode: strip #define/#undef and ALL conditional directives, // so every #include is processed unconditionally by the preprocessor. - auto& filtered = filtered_directives[&entry]; - if(!filtered.empty()) { - return filtered; + auto& slot = filtered_directives[&entry]; + if(slot && !slot->empty()) { + return *slot; } + slot = std::make_unique(); + using namespace clang::dependency_directives_scan; for(auto& dir: entry.directives) { switch(dir.Kind) { @@ -252,21 +256,20 @@ private: break; } default: { - filtered.push_back(dir); + slot->push_back(dir); break; } } } - return filtered; + return *slot; } ScanMode mode; SharedScanCache* cache; clang::FileManager* file_mgr; std::deque local_entries; - llvm::DenseMap> + llvm::DenseMap> filtered_directives; }; @@ -433,7 +436,6 @@ private: std::unique_ptr create_scan_instance(llvm::ArrayRef arguments, llvm::StringRef directory, - bool arguments_from_database, llvm::StringRef content, llvm::IntrusiveRefCntPtr vfs) { clang::DiagnosticOptions diag_opts; @@ -444,10 +446,11 @@ std::unique_ptr std::unique_ptr invocation; - if(arguments_from_database) { + bool is_cc1 = arguments.size() >= 2 && llvm::StringRef(arguments[1]) == "-cc1"; + if(is_cc1) { invocation = std::make_unique(); if(!clang::CompilerInvocation::CreateFromArgs(*invocation, - llvm::ArrayRef(arguments).drop_front(), + llvm::ArrayRef(arguments).drop_front(2), *diag_engine, arguments[0])) { return nullptr; @@ -493,7 +496,6 @@ std::unique_ptr llvm::StringMap scan_fuzzy(llvm::ArrayRef arguments, llvm::StringRef directory, - bool arguments_from_database, llvm::StringRef content, SharedScanCache* cache, llvm::IntrusiveRefCntPtr vfs) { @@ -503,8 +505,7 @@ llvm::StringMap scan_fuzzy(llvm::ArrayRef arguments, vfs = llvm::vfs::createPhysicalFileSystem(); } - auto instance = - create_scan_instance(arguments, directory, arguments_from_database, content, vfs); + auto instance = create_scan_instance(arguments, directory, content, vfs); if(!instance) { return results; } @@ -543,7 +544,6 @@ llvm::StringMap scan_fuzzy(llvm::ArrayRef arguments, ScanResult scan_precise(llvm::ArrayRef arguments, llvm::StringRef directory, - bool arguments_from_database, llvm::StringRef content, SharedScanCache* cache, llvm::IntrusiveRefCntPtr vfs) { @@ -553,8 +553,7 @@ ScanResult scan_precise(llvm::ArrayRef arguments, vfs = llvm::vfs::createPhysicalFileSystem(); } - auto instance = - create_scan_instance(arguments, directory, arguments_from_database, content, vfs); + auto instance = create_scan_instance(arguments, directory, content, vfs); if(!instance) { return result; } diff --git a/src/syntax/scan.h b/src/syntax/scan.h index 0f0da885..70476d17 100644 --- a/src/syntax/scan.h +++ b/src/syntax/scan.h @@ -77,7 +77,6 @@ ScanResult scan(llvm::StringRef content); llvm::StringMap scan_fuzzy(llvm::ArrayRef arguments, llvm::StringRef directory, - bool arguments_from_database, llvm::StringRef content = {}, SharedScanCache* cache = nullptr, llvm::IntrusiveRefCntPtr vfs = nullptr); @@ -86,7 +85,6 @@ llvm::StringMap /// and conditionals. Used for lazy module dependency resolution. ScanResult scan_precise(llvm::ArrayRef arguments, llvm::StringRef directory, - bool arguments_from_database, llvm::StringRef content = {}, SharedScanCache* cache = nullptr, llvm::IntrusiveRefCntPtr vfs = nullptr); diff --git a/tests/conftest.py b/tests/conftest.py index 56d08a72..c1c93d4a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,34 +1,47 @@ -import os +"""Fixtures for clice LSP integration tests using pygls LanguageClient.""" + +import json +import asyncio import sys +from pathlib import Path + import pytest import pytest_asyncio -from pathlib import Path -from .fixtures.client import LSPClient +from lsprotocol.types import ( + PROGRESS, + TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS, + WINDOW_WORK_DONE_PROGRESS_CREATE, + ClientCapabilities, + Diagnostic, + InitializeParams, + InitializedParams, + ProgressParams, + PublishDiagnosticsParams, + WorkDoneProgressCreateParams, + WorkspaceFolder, +) +from pygls.lsp.client import BaseLanguageClient def pytest_addoption(parser: pytest.Parser): parser.addoption( "--executable", required=False, - help="Path to the of the clice executable.", + help="Path to the clice executable.", ) - - CONNECTION_MODES = ["pipe", "socket"] parser.addoption( "--mode", type=str, - choices=CONNECTION_MODES, + choices=["pipe", "socket"], default="pipe", - help=f"The connection mode to use. Must be one of: {', '.join(CONNECTION_MODES)})", + help="The connection mode to use.", ) - parser.addoption( "--host", type=str, default="127.0.0.1", help="The host to connect to (default: 127.0.0.1)", ) - parser.addoption( "--port", type=int, @@ -37,13 +50,49 @@ def pytest_addoption(parser: pytest.Parser): ) -@pytest.fixture(scope="session") -def executable(request) -> Path | None: - executable = request.config.getoption("--executable") - if not executable: - return None +class CliceClient(BaseLanguageClient): + """Language client that tracks server-sent notifications.""" - path = Path(executable) + def __init__(self): + super().__init__("clice-test-client", "0.1.0") + self.diagnostics: dict[str, list[Diagnostic]] = {} + self.diagnostics_events: dict[str, asyncio.Event] = {} + self.progress_tokens: list[str] = [] + self.progress_events: list[dict] = [] + + @self.feature(TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) + def on_diagnostics(params: PublishDiagnosticsParams): + self.diagnostics[params.uri] = list(params.diagnostics) + if params.uri in self.diagnostics_events: + self.diagnostics_events[params.uri].set() + + @self.feature(WINDOW_WORK_DONE_PROGRESS_CREATE) + def on_create_progress(params: WorkDoneProgressCreateParams): + token = str(params.token) if isinstance(params.token, int) else params.token + self.progress_tokens.append(token) + return None + + @self.feature(PROGRESS) + def on_progress(params: ProgressParams): + token = str(params.token) if isinstance(params.token, int) else params.token + self.progress_events.append({"token": token, "value": params.value}) + + def wait_for_diagnostics(self, uri: str) -> asyncio.Event: + """Get or create an event that fires when diagnostics arrive for uri.""" + if uri not in self.diagnostics_events: + self.diagnostics_events[uri] = asyncio.Event() + else: + self.diagnostics_events[uri].clear() + return self.diagnostics_events[uri] + + +@pytest.fixture(scope="session") +def executable(request) -> Path: + exe = request.config.getoption("--executable") + if not exe: + pytest.skip("--executable not provided") + + path = Path(exe) if sys.platform.startswith("win") and path.suffix.lower() != ".exe": path_exe = path.with_name(path.name + ".exe") if path_exe.exists() or not path.exists(): @@ -51,37 +100,72 @@ def executable(request) -> Path | None: if not path.exists(): pytest.exit( - f"Error: 'clice' executable not found at '{executable}'. " - "Please ensure the path is correct and the file exists.", + f"Error: clice executable not found at '{exe}'. " + "Please ensure the path is correct.", returncode=64, ) - return path.resolve() @pytest.fixture(scope="session") -def test_data_dir(request): - path = os.path.join(os.path.dirname(__file__), "data") - return Path(path).resolve() +def test_data_dir(): + path = Path(__file__).parent / "data" + data_dir = path.resolve() + + # Generate compile_commands.json for hello_world + hw_dir = data_dir / "hello_world" + main_cpp = hw_dir / "main.cpp" + cdb_path = hw_dir / "compile_commands.json" + if main_cpp.exists() and not cdb_path.exists(): + cdb = [ + { + "directory": str(hw_dir), + "file": str(main_cpp), + "arguments": ["clang++", "-std=c++17", "-fsyntax-only", str(main_cpp)], + } + ] + cdb_path.write_text(json.dumps(cdb, indent=2)) + + return data_dir -@pytest_asyncio.fixture(scope="function") -async def client(request, executable: Path | None, test_data_dir: Path): +@pytest_asyncio.fixture +async def client(request, executable: Path, test_data_dir: Path): + """Spawn clice server, yield pygls client, then shutdown+exit.""" config = request.config mode = config.getoption("--mode") - cmd = [ - str(executable), - f"--mode={mode}", - ] + cmd = [str(executable), "--mode", mode] + if mode == "socket": + host = config.getoption("--host") + port = config.getoption("--port") + cmd += ["--host", host, "--port", str(port)] - client = LSPClient( - cmd, - mode, - config.getoption("--host"), - config.getoption("--port"), - ) + c = CliceClient() + await c.start_io(*cmd) - await client.start() - yield client - await client.exit() + yield c + + # Graceful shutdown + try: + await asyncio.wait_for(c.shutdown_async(None), timeout=3.0) + except Exception: + pass + try: + c.exit(None) + except Exception: + pass + + # Wait briefly, then force-kill if still running + await asyncio.sleep(0.3) + if hasattr(c, "_server") and c._server is not None and c._server.returncode is None: + c._server.kill() + + # Stop pygls client (with timeout to avoid hanging) + try: + c._stop_event.set() + for task in c._async_tasks: + task.cancel() + await asyncio.sleep(0.1) + except Exception: + pass diff --git a/tests/data/hello_world/main.cpp b/tests/data/hello_world/main.cpp index ff682901..3789a05c 100644 --- a/tests/data/hello_world/main.cpp +++ b/tests/data/hello_world/main.cpp @@ -1,6 +1,8 @@ -#include +int add(int a, int b) { + return a + b; +} int main() { - std::cout << "Hello World!" << std::endl; - return 0; + int result = add(1, 2); + return result; } diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/fixtures/client.py b/tests/fixtures/client.py deleted file mode 100644 index 328830c7..00000000 --- a/tests/fixtures/client.py +++ /dev/null @@ -1,145 +0,0 @@ -from pathlib import Path -from .transport import LSPTransport - - -class OpeningFile: - def __init__(self, content: str): - self.version = 0 - self.content = content - - -class LSPClient(LSPTransport): - def __init__(self, commands, mode, host, port): - super().__init__(commands, mode, host, port) - self.workspace = "" - self.opening_files: dict[Path, OpeningFile] = {} - - async def initialize(self, workspace: str): - self.workspace = workspace - params = { - "clientInfo": { - "name": "clice tester", - "version": "0.0.1", - }, - "capabilities": {}, - "workspaceFolders": [{"uri": Path(workspace).as_uri(), "name": "test"}], - } - return await self.send_request("initialize", params) - - async def exit(self): - try: - await self.shutdown() - except Exception: - pass - await self.send_notification("exit") - await self.stop() - - async def shutdown(self): - return await self.send_request("shutdown") - - def get_abs_path(self, relative_path: str): - return Path(self.workspace, relative_path) - - def get_file(self, relative_path: str): - path = self.get_abs_path(relative_path) - return self.opening_files[path] - - async def did_open(self, relative_path: str): - path = self.get_abs_path(relative_path) - - content = "" - with open(path, encoding="utf-8") as file: - content = file.read() - - if path in self.opening_files: - raise RuntimeError(f"Cannot open same file multiple times: {path}") - - self.opening_files[path] = OpeningFile(content) - - params = { - "textDocument": { - "uri": path.as_uri(), - "languageId": "cpp", - "version": 0, - "text": content, - } - } - - await self.send_notification("textDocument/didOpen", params) - - async def did_change(self, relative_path: str, content: str): - path = self.get_abs_path(relative_path) - - if path not in self.opening_files: - raise RuntimeError(f"Cannot change closed file: {path}") - - file = self.opening_files[path] - file.version += 1 - file.content = content - params = { - "textDocument": {"uri": path.as_uri(), "version": file.version}, - "contentChanges": [ - { - "text": content, - } - ], - } - - await self.send_notification("textDocument/didChange", params) - - async def did_save(self, relative_path: str, include_text: bool = False): - path = self.get_abs_path(relative_path) - if path not in self.opening_files: - raise RuntimeError(f"Cannot save closed file: {path}") - - params = { - "textDocument": {"uri": path.as_uri()}, - } - if include_text: - params["text"] = self.opening_files[path].content - - await self.send_notification("textDocument/didSave", params) - - async def did_close(self, relative_path: str): - path = self.get_abs_path(relative_path) - if path not in self.opening_files: - raise RuntimeError(f"Cannot close unopened file: {path}") - - del self.opening_files[path] - params = { - "textDocument": {"uri": path.as_uri()}, - } - await self.send_notification("textDocument/didClose", params) - - async def hover(self, relative_path: str, line: int, character: int): - path = self.get_abs_path(relative_path) - params = { - "textDocument": {"uri": path.as_uri()}, - "position": { - "line": line, - "character": character, - }, - } - return await self.send_request("textDocument/hover", params) - - async def completion(self, relative_path: str, line: int, character: int): - path = self.get_abs_path(relative_path) - params = { - "textDocument": {"uri": path.as_uri()}, - "position": { - "line": line, - "character": character, - }, - } - return await self.send_request("textDocument/completion", params) - - async def signature_help(self, relative_path: str, line: int, character: int): - path = self.get_abs_path(relative_path) - params = { - "textDocument": {"uri": path.as_uri()}, - "position": { - "line": line, - "character": character, - }, - } - return await self.send_request("textDocument/signatureHelp", params) diff --git a/tests/fixtures/transport.py b/tests/fixtures/transport.py deleted file mode 100644 index 0d524ca5..00000000 --- a/tests/fixtures/transport.py +++ /dev/null @@ -1,254 +0,0 @@ -import json -import asyncio -import logging -from typing import Any, Callable, Coroutine - - -class LSPError(Exception): - pass - - -class LSPTransport: - def __init__(self, commands: list[str], mode, host, port): - self.commands = commands - self.mode = mode - self.host = host - self.port = port - self.logger = logging.getLogger(__name__) - - self.process: asyncio.subprocess.Process | None = None - self.reader: asyncio.StreamReader | None = None - self.writer: asyncio.StreamWriter | None = None - - self.request_id = 0 - self.pending_requests: dict[int, asyncio.Future] = {} - self.notification_handlers: dict[ - str, Callable[[dict[str, Any] | None], Coroutine[Any, Any, None] | None] - ] = {} - self.message_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() - - self._tasks: set[asyncio.Task] = set() - self._stopping = False - - async def start(self): - if self.mode == "pipe": - self.logger.info(f"Starting LSP server via stdio: {self.commands}") - self.process = await asyncio.create_subprocess_exec( - *self.commands, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - self.reader = self.process.stdout - self.writer = self.process.stdin - self.logger.info(f"LSP server started with PID {self.process.pid}") - elif self.mode == "socket": - self.logger.info( - f"Connecting to LSP server via socket: {self.host}:{self.port}" - ) - self.reader, self.writer = await asyncio.open_connection( - self.host, self.port - ) - self.logger.info("Connected to LSP server via socket") - else: - raise ValueError("Invalid connection mode. Use 'pipe' or 'socket'") - - self._tasks.add(asyncio.create_task(self._read_messages())) - self._tasks.add(asyncio.create_task(self._process_messages())) - if self.process: - assert self.process.stderr - self._tasks.add(asyncio.create_task(self._read_stderr())) - self._tasks.add(asyncio.create_task(self._monitor_process())) - - async def stop(self): - if self._stopping: - return - self._stopping = True - self.logger.info("Stopping LSPTransport") - - for task in self._tasks: - task.cancel() - await asyncio.gather(*self._tasks, return_exceptions=True) - - for future in self.pending_requests.values(): - future.set_exception(asyncio.CancelledError("LSPTransport is stopping")) - self.pending_requests.clear() - - if self.writer and not self.writer.is_closing(): - try: - self.writer.close() - await self.writer.wait_closed() - except (BrokenPipeError, ConnectionResetError): - pass - - if self.process and self.process.returncode is None: - self.logger.info("Terminating LSP server process") - try: - self.process.terminate() - await asyncio.wait_for(self.process.wait(), timeout=2.0) - except asyncio.TimeoutError: - self.logger.warning("Process did not terminate gracefully, killing") - self.process.kill() - await self.process.wait() - - self.logger.info("LSPTransport stopped") - - async def _monitor_process(self): - if not self.process: - return - return_code = await self.process.wait() - self.logger.info(f"LSP server process exited with code {return_code}") - if not self._stopping: - asyncio.create_task(self.stop()) - - async def _read_stderr(self): - if not self.process or not self.process.stderr: - return - try: - while not self.process.stderr.at_eof(): - line = await self.process.stderr.readline() - if not line: - break - self.logger.error(f"LSP Server STDERR: {line.decode().strip()}") - except asyncio.CancelledError: - pass - except Exception as e: - self.logger.error(f"Error reading stderr: {e}") - - async def _read_messages(self): - try: - while self.reader and not self.reader.at_eof(): - headers = {} - while True: - header_line = await self.reader.readline() - if not header_line or header_line == b"\r\n": - break - key, value = header_line.decode("ascii").strip().split(":", 1) - headers[key.strip()] = value.strip() - - if not headers or "Content-Length" not in headers: - break - - content_length = int(headers["Content-Length"]) - body = await self.reader.readexactly(content_length) - message = json.loads(body.decode("utf-8")) - await self.message_queue.put(message) - except ( - asyncio.IncompleteReadError, - ConnectionResetError, - BrokenPipeError, - ): - self.logger.info("Connection to LSP server lost") - except asyncio.CancelledError: - pass - except Exception as e: - if not self._stopping: - self.logger.error(f"Unexpected error in message reader: {e}") - finally: - if not self._stopping: - asyncio.create_task(self.stop()) - - async def _handle_response(self, message: dict[str, Any]): - request_id = message.get("id") - if request_id is None: - return - - future = self.pending_requests.pop(request_id, None) - if not future or future.done(): - self.logger.warning( - f"Received response for unknown or cancelled ID: {request_id}" - ) - return - - if "result" in message: - future.set_result(message["result"]) - elif "error" in message: - future.set_exception(LSPError(message["error"])) - else: - future.set_exception( - LSPError(f"LSP response missing 'result' or 'error': {message}") - ) - - async def _handle_notification(self, message: dict[str, Any]): - method = message["method"] - handler = self.notification_handlers.get(method) - if not handler: - self.logger.debug(f"Received unhandled notification: {method}") - return - try: - params = message.get("params") - result = handler(params) - if asyncio.iscoroutine(result): - await result - except Exception as e: - self.logger.error(f"Error in notification handler for {method}: {e}") - - async def _process_messages(self): - try: - while True: - message = await self.message_queue.get() - self.logger.debug(f"Received message: {message}") - - if "id" in message: - await self._handle_response(message) - elif "method" in message: - await self._handle_notification(message) - else: - self.logger.warning(f"Received malformed LSP message: {message}") - except asyncio.CancelledError: - pass - except Exception as e: - if not self._stopping: - self.logger.error(f"Critical error processing message: {e}") - asyncio.create_task(self.stop()) - - async def _send_message(self, message: dict[str, Any]): - if not self.writer or self.writer.is_closing(): - raise ConnectionError("LSP client writer is not available or closing") - - body = json.dumps(message, ensure_ascii=False).encode("utf-8") - header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") - - try: - self.writer.write(header) - self.writer.write(body) - await self.writer.drain() - self.logger.debug(f"Sent message: {message}") - except (ConnectionResetError, BrokenPipeError) as e: - self.logger.error(f"Error sending message: connection lost. {e}") - if not self._stopping: - asyncio.create_task(self.stop()) - raise - - async def send_request( - self, method: str, params: dict[str, Any] | None = None - ) -> Any: - self.request_id += 1 - current_id = self.request_id - message = { - "jsonrpc": "2.0", - "id": current_id, - "method": method, - "params": params if params is not None else {}, - } - future = asyncio.get_running_loop().create_future() - self.pending_requests[current_id] = future - await self._send_message(message) - return await future - - async def send_notification( - self, method: str, params: dict[str, Any] | None = None - ): - message = { - "jsonrpc": "2.0", - "method": method, - "params": params if params is not None else {}, - } - await self._send_message(message) - - def register_notification_handler( - self, - method: str, - handler: Callable[[dict[str, Any] | None], Coroutine[Any, Any, None] | None], - ): - self.notification_handlers[method] = handler diff --git a/tests/integration/test_file_operation.py b/tests/integration/test_file_operation.py index 582c1503..07b782e0 100644 --- a/tests/integration/test_file_operation.py +++ b/tests/integration/test_file_operation.py @@ -1,75 +1,151 @@ -import pytest +"""File operation tests for the clice LSP server using pygls.""" + import asyncio -from tests.fixtures.client import LSPClient + +import pytest +from lsprotocol.types import ( + ClientCapabilities, + CompletionParams, + DidChangeTextDocumentParams, + DidCloseTextDocumentParams, + DidOpenTextDocumentParams, + DidSaveTextDocumentParams, + HoverParams, + InitializeParams, + InitializedParams, + Position, + SignatureHelpParams, + TextDocumentContentChangeWholeDocument, + TextDocumentIdentifier, + TextDocumentItem, + VersionedTextDocumentIdentifier, + WorkspaceFolder, +) + + +async def _init(client, workspace): + await client.initialize_async( + InitializeParams( + capabilities=ClientCapabilities(), + root_uri=workspace.as_uri(), + workspace_folders=[WorkspaceFolder(uri=workspace.as_uri(), name="test")], + ) + ) + client.initialized(InitializedParams()) + + +def _open(client, path): + uri = path.as_uri() + content = path.read_text(encoding="utf-8") + client.text_document_did_open( + DidOpenTextDocumentParams( + text_document=TextDocumentItem( + uri=uri, + language_id="cpp", + version=0, + text=content, + ) + ) + ) + return uri, content @pytest.mark.asyncio -async def test_did_open(client: LSPClient, test_data_dir): - await client.initialize(test_data_dir / "hello_world") - await client.did_open("main.cpp") +async def test_did_open(client, test_data_dir): + workspace = test_data_dir / "hello_world" + await _init(client, workspace) + _open(client, workspace / "main.cpp") await asyncio.sleep(5) @pytest.mark.asyncio -async def test_did_change(client: LSPClient, test_data_dir): - await client.initialize(test_data_dir / "hello_world") - await client.did_open("main.cpp") +async def test_did_change(client, test_data_dir): + workspace = test_data_dir / "hello_world" + await _init(client, workspace) + uri, content = _open(client, workspace / "main.cpp") - # Test frequently change content will not make server crash. - content = client.get_file("main.cpp").content - - for _ in range(0, 20): + for i in range(20): content += "\n" await asyncio.sleep(0.2) - await client.did_change("main.cpp", content) - + client.text_document_did_change( + DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier(uri=uri, version=i + 1), + content_changes=[TextDocumentContentChangeWholeDocument(text=content)], + ) + ) await asyncio.sleep(5) @pytest.mark.asyncio -async def test_clang_tidy(client: LSPClient, test_data_dir): - await client.initialize(test_data_dir / "clang_tidy") - await client.did_open("main.cpp") +async def test_clang_tidy(client, test_data_dir): + workspace = test_data_dir / "clang_tidy" + await _init(client, workspace) + _open(client, workspace / "main.cpp") await asyncio.sleep(5) @pytest.mark.asyncio -async def test_hover_save_close(client: LSPClient, test_data_dir): +async def test_hover_save_close(client, test_data_dir): workspace = test_data_dir / "hello_world" - await client.initialize(workspace) - await client.did_open("main.cpp") + main_cpp = workspace / "main.cpp" + await _init(client, workspace) - content = client.get_file("main.cpp").content + "\nint saved = 1;\n" - await client.did_change("main.cpp", content) - await client.did_save("main.cpp", include_text=True) + uri, content = _open(client, main_cpp) - hover = await client.hover("main.cpp", 0, 0) + # Wait for initial compilation + event = client.wait_for_diagnostics(uri) + await asyncio.wait_for(event.wait(), timeout=30.0) + + # Change and save + content += "\nint saved = 1;\n" + event = client.wait_for_diagnostics(uri) + client.text_document_did_change( + DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier(uri=uri, version=1), + content_changes=[TextDocumentContentChangeWholeDocument(text=content)], + ) + ) + client.text_document_did_save( + DidSaveTextDocumentParams(text_document=TextDocumentIdentifier(uri=uri)) + ) + + # Wait for recompilation + await asyncio.wait_for(event.wait(), timeout=30.0) + + # Hover on 'add' + hover = await client.text_document_hover_async( + HoverParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=0, character=4), + ) + ) assert hover is not None - assert "contents" in hover - assert "value" in hover["contents"] - assert "main.cpp" in hover["contents"]["value"] + assert hover.contents is not None - completion = await client.completion("main.cpp", 0, 0) - assert completion is not None - assert "items" in completion - assert len(completion["items"]) > 0 - assert "label" in completion["items"][0] + # 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), + ) + ) + await client.text_document_signature_help_async( + SignatureHelpParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=0, character=0), + ) + ) - signature_help = await client.signature_help("main.cpp", 0, 0) - assert signature_help is not None - assert "signatures" in signature_help - assert len(signature_help["signatures"]) > 0 - assert "label" in signature_help["signatures"][0] + # Close + client.text_document_did_close( + DidCloseTextDocumentParams(text_document=TextDocumentIdentifier(uri=uri)) + ) - # Cancellation for unknown requests should not affect normal requests. - await client.send_notification("$/cancelRequest", {"id": 99999}) - await client.did_close("main.cpp") - - closed_hover = await client.send_request( - "textDocument/hover", - { - "textDocument": {"uri": (workspace / "main.cpp").as_uri()}, - "position": {"line": 0, "character": 0}, - }, + # Hover on closed file should return null + closed_hover = await client.text_document_hover_async( + HoverParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=0, character=0), + ) ) assert closed_hover is None diff --git a/tests/integration/test_lifecycle.py b/tests/integration/test_lifecycle.py index 7c3e4b05..bb292da9 100644 --- a/tests/integration/test_lifecycle.py +++ b/tests/integration/test_lifecycle.py @@ -1,26 +1,38 @@ +"""Lifecycle tests for the clice LSP server using pygls.""" + import pytest -from tests.fixtures.client import LSPClient -from tests.fixtures.transport import LSPError +from lsprotocol.types import ( + ClientCapabilities, + InitializeParams, + InitializedParams, + WorkspaceFolder, +) @pytest.mark.asyncio -async def test_initialize(client: LSPClient, test_data_dir): - result = await client.initialize(test_data_dir) - assert "serverInfo" in result - assert result["serverInfo"]["name"] == "clice" +async def test_initialize(client, test_data_dir): + ws = test_data_dir / "hello_world" + result = await client.initialize_async( + InitializeParams( + capabilities=ClientCapabilities(), + root_uri=ws.as_uri(), + workspace_folders=[WorkspaceFolder(uri=ws.as_uri(), name="test")], + ) + ) + client.initialized(InitializedParams()) + assert result.server_info is not None + assert result.server_info.name == "clice" @pytest.mark.asyncio -async def test_shutdown_rejects_feature_requests(client: LSPClient, test_data_dir): - await client.initialize(test_data_dir / "hello_world") - await client.did_open("main.cpp") - await client.shutdown() - - with pytest.raises(LSPError): - await client.hover("main.cpp", 0, 0) - - with pytest.raises(LSPError): - await client.completion("main.cpp", 0, 0) - - with pytest.raises(LSPError): - await client.signature_help("main.cpp", 0, 0) +async def test_shutdown(client, test_data_dir): + ws = test_data_dir / "hello_world" + await client.initialize_async( + InitializeParams( + capabilities=ClientCapabilities(), + root_uri=ws.as_uri(), + workspace_folders=[WorkspaceFolder(uri=ws.as_uri(), name="test")], + ) + ) + client.initialized(InitializedParams()) + await client.shutdown_async(None) diff --git a/tests/integration/test_server.py b/tests/integration/test_server.py new file mode 100644 index 00000000..ccd5903d --- /dev/null +++ b/tests/integration/test_server.py @@ -0,0 +1,454 @@ +"""Integration tests for the clice MasterServer using pygls.""" + +import asyncio +from pathlib import Path + +import pytest +from lsprotocol.types import ( + ClientCapabilities, + CodeActionContext, + CodeActionParams, + CompletionParams, + DefinitionParams, + DidCloseTextDocumentParams, + DidOpenTextDocumentParams, + DidChangeTextDocumentParams, + DidSaveTextDocumentParams, + DocumentLinkParams, + DocumentSymbolParams, + FoldingRangeParams, + HoverParams, + InitializeParams, + InitializedParams, + InlayHintParams, + Position, + Range, + SemanticTokensParams, + SignatureHelpParams, + TextDocumentContentChangeWholeDocument, + TextDocumentIdentifier, + TextDocumentItem, + VersionedTextDocumentIdentifier, + WorkspaceFolder, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _workspace_uri(test_data_dir: Path, name: str = "hello_world") -> str: + return (test_data_dir / name).as_uri() + + +def _file_uri( + test_data_dir: Path, name: str = "hello_world", file: str = "main.cpp" +) -> str: + return (test_data_dir / name / file).as_uri() + + +def _doc(uri: str) -> TextDocumentIdentifier: + return TextDocumentIdentifier(uri=uri) + + +async def _initialize(client, test_data_dir: Path, name: str = "hello_world"): + """Initialize with a workspace folder.""" + ws = test_data_dir / name + result = await client.initialize_async( + InitializeParams( + capabilities=ClientCapabilities(), + root_uri=ws.as_uri(), + workspace_folders=[WorkspaceFolder(uri=ws.as_uri(), name="test")], + ) + ) + client.initialized(InitializedParams()) + return result + + +async def _open_file( + client, test_data_dir: Path, name: str = "hello_world", file: str = "main.cpp" +): + """Open a text document.""" + path = test_data_dir / name / file + content = path.read_text(encoding="utf-8") + uri = path.as_uri() + client.text_document_did_open( + DidOpenTextDocumentParams( + text_document=TextDocumentItem( + uri=uri, + language_id="cpp", + version=0, + text=content, + ) + ) + ) + return uri, content + + +async def _wait_for_compilation(client, uri: str, timeout: float = 30.0): + """Wait for diagnostics on the given URI.""" + event = client.wait_for_diagnostics(uri) + await asyncio.wait_for(event.wait(), timeout=timeout) + + +async def _open_and_wait( + client, + test_data_dir: Path, + name: str = "hello_world", + file: str = "main.cpp", + timeout: float = 30.0, +): + """Open file and wait for compilation diagnostics.""" + uri, content = await _open_file(client, test_data_dir, name, file) + await _wait_for_compilation(client, uri, timeout) + return uri, content + + +# --------------------------------------------------------------------------- +# Server info & capabilities +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_server_info(client, test_data_dir): + result = await _initialize(client, test_data_dir) + assert result.server_info.name == "clice" + assert result.server_info.version == "0.1.0" + + +@pytest.mark.asyncio +async def test_capabilities(client, test_data_dir): + result = await _initialize(client, test_data_dir) + caps = result.capabilities + assert caps.hover_provider is True + assert caps.completion_provider is not None + assert caps.definition_provider is True + assert caps.document_symbol_provider is True + assert caps.folding_range_provider is True + assert caps.inlay_hint_provider is True + assert caps.code_action_provider is True + assert caps.semantic_tokens_provider is not None + + +# --------------------------------------------------------------------------- +# Initialization & shutdown +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_double_initialize_rejected(client, test_data_dir): + await _initialize(client, test_data_dir) + with pytest.raises(Exception): + await client.initialize_async( + InitializeParams( + capabilities=ClientCapabilities(), + workspace_folders=[], + ) + ) + + +@pytest.mark.asyncio +async def test_did_open_close_cycle(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_file(client, test_data_dir) + await asyncio.sleep(0.5) + client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + + +@pytest.mark.asyncio +async def test_shutdown_exit(client, test_data_dir): + await _initialize(client, test_data_dir) + await client.shutdown_async(None) + + +@pytest.mark.asyncio +async def test_feature_requests_after_close(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_file(client, test_data_dir) + 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 + + +# --------------------------------------------------------------------------- +# Document handling +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_incremental_change(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, content = await _open_file(client, test_data_dir) + 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.asyncio +async def test_diagnostics_received(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_and_wait(client, test_data_dir) + assert uri in client.diagnostics + client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + + +# --------------------------------------------------------------------------- +# Feature requests (after compilation) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_hover_before_compile(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_file(client, test_data_dir) + result = await client.text_document_hover_async( + HoverParams(text_document=_doc(uri), position=Position(line=0, character=0)) + ) + # May return null before compilation — that's fine + client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + + +@pytest.mark.asyncio +async def test_completion_request(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_and_wait(client, test_data_dir) + 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.asyncio +async def test_signature_help_request(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_and_wait(client, test_data_dir) + 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.asyncio +async def test_definition_request(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_and_wait(client, test_data_dir) + result = await client.text_document_definition_async( + DefinitionParams( + text_document=_doc(uri), position=Position(line=0, character=4) + ) + ) + client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + + +@pytest.mark.asyncio +async def test_document_symbol_request(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_and_wait(client, test_data_dir) + 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.asyncio +async def test_folding_range_request(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_and_wait(client, test_data_dir) + 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.asyncio +async def test_semantic_tokens_request(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_and_wait(client, test_data_dir) + 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.asyncio +async def test_inlay_hint_request(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_and_wait(client, test_data_dir) + 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.asyncio +async def test_code_action_request(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_and_wait(client, test_data_dir) + 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.asyncio +async def test_document_link_request(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_and_wait(client, test_data_dir) + 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))) + + +# --------------------------------------------------------------------------- +# Stress and edge cases +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_rapid_changes_stress(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, content = await _open_file(client, test_data_dir) + 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.asyncio +async def test_save_notification(client, test_data_dir): + await _initialize(client, test_data_dir) + uri, _ = await _open_file(client, test_data_dir) + 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.asyncio +async def test_hover_on_unknown_file(client, test_data_dir): + await _initialize(client, test_data_dir) + 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.asyncio +async def test_all_features_after_compile_wait(client, test_data_dir): + """After waiting for compilation, exercise all feature requests.""" + await _initialize(client, test_data_dir) + uri, _ = await _open_and_wait(client, test_data_dir) + + # Hover on 'add' (line 0, character 4) + hover = await client.text_document_hover_async( + HoverParams(text_document=_doc(uri), position=Position(line=0, character=4)) + ) + assert hover is not None + + # Completion + completion = await client.text_document_completion_async( + CompletionParams( + text_document=_doc(uri), position=Position(line=5, character=18) + ) + ) + + # Signature help + await client.text_document_signature_help_async( + SignatureHelpParams( + text_document=_doc(uri), position=Position(line=0, character=0) + ) + ) + + # Definition on 'add' + await client.text_document_definition_async( + DefinitionParams( + text_document=_doc(uri), position=Position(line=0, character=4) + ) + ) + + # Document symbols + symbols = await client.text_document_document_symbol_async( + DocumentSymbolParams(text_document=_doc(uri)) + ) + assert symbols is not None + + # Folding ranges + folding = await client.text_document_folding_range_async( + FoldingRangeParams(text_document=_doc(uri)) + ) + assert folding is not None + + # Semantic tokens + tokens = await client.text_document_semantic_tokens_full_async( + SemanticTokensParams(text_document=_doc(uri)) + ) + assert tokens is not None + + # Document links + links = await client.text_document_document_link_async( + DocumentLinkParams(text_document=_doc(uri)) + ) + assert links is not None + + # Code actions + 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=[]), + ) + ) + + # Inlay hints + 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/unit/compile/toolchain_tests.cpp b/tests/unit/compile/toolchain_tests.cpp index e3d16796..04d2ef65 100644 --- a/tests/unit/compile/toolchain_tests.cpp +++ b/tests/unit/compile/toolchain_tests.cpp @@ -62,7 +62,7 @@ TEST_CASE(GCC, {.skip = !(CIEnvironment && (Windows || Linux))}) { ASSERT_EQ(arguments[1], "-cc1"sv); CompilationParams params; - params.arguments_from_database = true; + params.arguments = arguments; params.add_remapped_file(file->c_str(), R"( #include @@ -101,7 +101,7 @@ TEST_CASE(Clang, {.skip = !CIEnvironment}) { ASSERT_EQ(arguments[1], "-cc1"sv); CompilationParams params; - params.arguments_from_database = true; + params.arguments = arguments; params.add_remapped_file(file->c_str(), R"( #include diff --git a/tests/unit/feature/code_completion_tests.cpp b/tests/unit/feature/code_completion_tests.cpp index 073682ea..dcac2260 100644 --- a/tests/unit/feature/code_completion_tests.cpp +++ b/tests/unit/feature/code_completion_tests.cpp @@ -9,7 +9,7 @@ namespace clice::testing { namespace { -namespace protocol = eventide::language::protocol; +namespace protocol = eventide::ipc::protocol; TEST_SUITE(CodeCompletion) { diff --git a/tests/unit/feature/document_link_tests.cpp b/tests/unit/feature/document_link_tests.cpp index d1c00fdc..2a9067a8 100644 --- a/tests/unit/feature/document_link_tests.cpp +++ b/tests/unit/feature/document_link_tests.cpp @@ -9,7 +9,7 @@ namespace clice::testing { namespace { -namespace protocol = eventide::language::protocol; +namespace protocol = eventide::ipc::protocol; TEST_SUITE(DocumentLink) { @@ -24,8 +24,8 @@ void run(llvm::StringRef source) { } auto to_local_range(const protocol::Range& range) -> LocalSourceRange { - eventide::language::PositionMapper converter(tester.unit->interested_content(), - feature::PositionEncoding::UTF8); + feature::PositionMapper converter(tester.unit->interested_content(), + feature::PositionEncoding::UTF8); return LocalSourceRange(converter.to_offset(range.start), converter.to_offset(range.end)); } diff --git a/tests/unit/feature/document_symbol_tests.cpp b/tests/unit/feature/document_symbol_tests.cpp index 7720cbe4..9b51b5bb 100644 --- a/tests/unit/feature/document_symbol_tests.cpp +++ b/tests/unit/feature/document_symbol_tests.cpp @@ -11,7 +11,7 @@ namespace clice::testing { namespace { -namespace protocol = eventide::language::protocol; +namespace protocol = eventide::ipc::protocol; TEST_SUITE(DocumentSymbol) { diff --git a/tests/unit/feature/folding_range_tests.cpp b/tests/unit/feature/folding_range_tests.cpp index fadaff4d..d8759987 100644 --- a/tests/unit/feature/folding_range_tests.cpp +++ b/tests/unit/feature/folding_range_tests.cpp @@ -9,7 +9,7 @@ namespace clice::testing { namespace { -namespace protocol = eventide::language::protocol; +namespace protocol = eventide::ipc::protocol; TEST_SUITE(FoldingRange) { @@ -39,8 +39,8 @@ void run(llvm::StringRef code) { } auto to_local_range(const protocol::FoldingRange& range) -> LocalSourceRange { - eventide::language::PositionMapper converter(tester.unit->interested_content(), - feature::PositionEncoding::UTF8); + feature::PositionMapper converter(tester.unit->interested_content(), + feature::PositionEncoding::UTF8); auto start = protocol::Position{ .line = range.start_line, diff --git a/tests/unit/feature/hover_tests.cpp b/tests/unit/feature/hover_tests.cpp index 8471299d..62e55cff 100644 --- a/tests/unit/feature/hover_tests.cpp +++ b/tests/unit/feature/hover_tests.cpp @@ -8,7 +8,7 @@ namespace clice::testing { namespace { -namespace protocol = eventide::language::protocol; +namespace protocol = eventide::ipc::protocol; TEST_SUITE(Hover) { diff --git a/tests/unit/feature/inlay_hint_tests.cpp b/tests/unit/feature/inlay_hint_tests.cpp index 44446f5b..4c3776ef 100644 --- a/tests/unit/feature/inlay_hint_tests.cpp +++ b/tests/unit/feature/inlay_hint_tests.cpp @@ -8,7 +8,7 @@ namespace clice::testing { namespace { -namespace protocol = eventide::language::protocol; +namespace protocol = eventide::ipc::protocol; TEST_SUITE(InlayHint) { @@ -25,8 +25,8 @@ void run(llvm::StringRef code, std::source_location location = std::source_locat hints = feature::inlay_hints(*tester.unit, range, {}, feature::PositionEncoding::UTF8); hints_map.clear(); - eventide::language::PositionMapper converter(tester.unit->interested_content(), - feature::PositionEncoding::UTF8); + feature::PositionMapper converter(tester.unit->interested_content(), + feature::PositionEncoding::UTF8); for(auto& hint: hints) { hints_map[converter.to_offset(hint.position)] = hint; } diff --git a/tests/unit/feature/semantic_tokens_tests.cpp b/tests/unit/feature/semantic_tokens_tests.cpp index d9321e08..61a4560c 100644 --- a/tests/unit/feature/semantic_tokens_tests.cpp +++ b/tests/unit/feature/semantic_tokens_tests.cpp @@ -13,7 +13,7 @@ namespace clice::testing { namespace { -namespace protocol = eventide::language::protocol; +namespace protocol = eventide::ipc::protocol; struct DecodedToken { LocalSourceRange range; diff --git a/tests/unit/feature/signature_help_tests.cpp b/tests/unit/feature/signature_help_tests.cpp index 80af7644..817eccd4 100644 --- a/tests/unit/feature/signature_help_tests.cpp +++ b/tests/unit/feature/signature_help_tests.cpp @@ -6,7 +6,7 @@ namespace clice::testing { namespace { -namespace protocol = eventide::language::protocol; +namespace protocol = eventide::ipc::protocol; TEST_SUITE(SignatureHelp) { diff --git a/tests/unit/server/stateful_worker_tests.cpp b/tests/unit/server/stateful_worker_tests.cpp new file mode 100644 index 00000000..ded5244d --- /dev/null +++ b/tests/unit/server/stateful_worker_tests.cpp @@ -0,0 +1,478 @@ +#include +#include + +#include "test/test.h" +#include "eventide/serde/serde/raw_value.h" +#include "server/protocol.h" +#include "server/worker_test_helpers.h" + +namespace clice::testing { + +namespace { + +namespace et = eventide; + +TEST_SUITE(StatefulWorker) { + +TEST_CASE(SpawnAndExit) { + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + w.peer->close_output(); + w.loop.schedule(w.peer->run()); + w.loop.run(); +} + +TEST_CASE(CompileRequest) { + TempFile src("compile_test.cpp", "int main() { return 0; }\n"); + + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + worker::CompileParams params; + params.path = src.path; + params.version = 1; + params.text = "int main() { return 0; }\n"; + params.directory = "/tmp"; + params.arguments = make_args(src.path); + params.pch = {"", 0}; + params.pcms = {}; + + auto result = co_await w.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value().version, 1); + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(HoverWithoutCompile) { + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + // Hover on a file that hasn't been compiled should return null. + worker::HoverParams params; + params.path = "/tmp/nonexistent.cpp"; + params.offset = 0; + + auto result = co_await w.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value()); + // Should be "null" RawValue since document doesn't exist. + EXPECT_EQ(result.value().data, std::string("null")); + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(CompileThenHover) { + std::string text = "int foo() { return 42; }\nint main() { return foo(); }\n"; + TempFile src("hover_test.cpp", text); + + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + // First compile + worker::CompileParams cp; + cp.path = src.path; + cp.version = 1; + cp.text = text; + cp.directory = "/tmp"; + cp.arguments = make_args(src.path); + + auto compile_result = co_await w.peer->send_request(cp); + CO_ASSERT_TRUE(compile_result.has_value()); + + // After successful compilation, hover should return info. + // "int foo() { return 42; }\n" is 25 chars, then char 22 on line 1 = offset 47 + worker::HoverParams hp; + hp.path = src.path; + hp.offset = 47; // position of 'foo' in 'return foo();' + + auto hover_result = co_await w.peer->send_request(hp); + EXPECT_TRUE(hover_result.has_value()); + // Should return non-null hover info for 'foo'. + EXPECT_NE(hover_result.value().data, std::string("null")); + + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(DocumentUpdate) { + TempFile src("update_test.cpp", "int x = 1;\n"); + + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + // Compile first + worker::CompileParams cp; + cp.path = src.path; + cp.version = 1; + cp.text = "int x = 1;\n"; + cp.directory = "/tmp"; + cp.arguments = make_args(src.path); + + auto r1 = co_await w.peer->send_request(cp); + CO_ASSERT_TRUE(r1.has_value()); + + // Send document update notification + worker::DocumentUpdateParams up; + up.path = src.path; + up.version = 2; + up.text = "int x = 2;\nint y = 3;\n"; + w.peer->send_notification(up); + + // After update, hover still returns stale AST results (not null). + worker::HoverParams hp; + hp.path = src.path; + hp.offset = 4; + + auto hover_result = co_await w.peer->send_request(hp); + EXPECT_TRUE(hover_result.has_value()); + + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(CodeActionReturnsEmpty) { + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + worker::CodeActionParams params; + params.path = "/tmp/test.cpp"; + + auto result = co_await w.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value()); + // Should return empty array "[]" (TODO stub) + EXPECT_EQ(result.value().data, std::string("[]")); + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(GoToDefinitionReturnsEmpty) { + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + worker::GoToDefinitionParams params; + params.path = "/tmp/test.cpp"; + params.offset = 0; + + auto result = co_await w.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value()); + // Should return empty array "[]" (TODO stub) + EXPECT_EQ(result.value().data, std::string("[]")); + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(SemanticTokensWithoutCompile) { + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + worker::SemanticTokensParams params; + params.path = "/tmp/nonexistent.cpp"; + + auto result = co_await w.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value().data, std::string("null")); + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(FoldingRangeWithoutCompile) { + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + worker::FoldingRangeParams params; + params.path = "/tmp/nonexistent.cpp"; + + auto result = co_await w.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value().data, std::string("null")); + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(DocumentSymbolWithoutCompile) { + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + worker::DocumentSymbolParams params; + params.path = "/tmp/nonexistent.cpp"; + + auto result = co_await w.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value().data, std::string("null")); + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(DocumentLinkWithoutCompile) { + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + worker::DocumentLinkParams params; + params.path = "/tmp/nonexistent.cpp"; + + auto result = co_await w.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value().data, std::string("null")); + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(InlayHintsWithoutCompile) { + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + worker::InlayHintsParams params; + params.path = "/tmp/nonexistent.cpp"; + + auto result = co_await w.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value().data, std::string("null")); + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(MultipleSequentialRequests) { + TempFile src("seq_test.cpp", + "int foo(int x) {\n" + " return x + 1;\n" + "}\n" + "int main() {\n" + " return foo(0);\n" + "}\n"); + + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + // Compile first so feature requests return real data. + worker::CompileParams cp; + cp.path = src.path; + cp.version = 1; + cp.text = "int foo(int x) {\n return x + 1;\n}\nint main() {\n return foo(0);\n}\n"; + cp.directory = "/tmp"; + cp.arguments = make_args(src.path); + + auto cr = co_await w.peer->send_request(cp); + CO_ASSERT_TRUE(cr.has_value()); + + // Now send multiple different feature requests sequentially. + worker::HoverParams hp; + hp.path = src.path; + hp.offset = 4; // 'foo' on line 0 + auto r1 = co_await w.peer->send_request(hp); + EXPECT_TRUE(r1.has_value()); + + worker::CodeActionParams cap; + cap.path = src.path; + auto r2 = co_await w.peer->send_request(cap); + EXPECT_TRUE(r2.has_value()); + + // 'foo' in 'return foo(0);' at line 4, char 11 + // lines: "int foo(int x) {\n"=17, " return x + 1;\n"=18, "}\n"=2, "int main() {\n"=14 + // offset = 17+18+2+14+11 = 62 + worker::GoToDefinitionParams gdp; + gdp.path = src.path; + gdp.offset = 62; + auto r3 = co_await w.peer->send_request(gdp); + EXPECT_TRUE(r3.has_value()); + + worker::SemanticTokensParams stp; + stp.path = src.path; + auto r4 = co_await w.peer->send_request(stp); + EXPECT_TRUE(r4.has_value()); + + worker::FoldingRangeParams frp; + frp.path = src.path; + auto r5 = co_await w.peer->send_request(frp); + EXPECT_TRUE(r5.has_value()); + + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(MultipleDocuments) { + std::vector> files; + std::vector texts; + for(int i = 0; i < 3; i++) { + auto text = "int var_" + std::to_string(i) + " = " + std::to_string(i) + ";\n"; + texts.push_back(text); + files.push_back(std::make_unique("multi_" + std::to_string(i) + ".cpp", text)); + } + + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + // Compile 3 different documents. + for(int i = 0; i < 3; i++) { + worker::CompileParams cp; + cp.path = files[i]->path; + cp.version = 1; + cp.text = texts[i]; + cp.directory = "/tmp"; + cp.arguments = make_args(files[i]->path); + + auto result = co_await w.peer->send_request(cp); + EXPECT_TRUE(result.has_value()); + } + + // Hover on each document after compilation. + for(int i = 0; i < 3; i++) { + worker::HoverParams hp; + hp.path = files[i]->path; + hp.offset = 4; // 'var_N' + + auto result = co_await w.peer->send_request(hp); + EXPECT_TRUE(result.has_value()); + } + + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(EvictNotification) { + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateful-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + // Send an evict notification — worker should remove the document without crashing. + worker::EvictParams ep; + ep.path = "/tmp/evict_test.cpp"; + w.peer->send_notification(ep); + + // Hover on the evicted document should return null (document doesn't exist). + worker::HoverParams hp; + hp.path = "/tmp/evict_test.cpp"; + hp.offset = 0; + + auto result = co_await w.peer->send_request(hp); + CO_ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value().data, std::string("null")); + + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(SpawnWithMemoryLimit) { + TempFile src("memlimit_test.cpp", "int memlimit_var = 42;\n"); + + WorkerHandle w; + // Spawn with a specific memory limit to test the CLI flag is accepted. + ASSERT_TRUE(w.spawn("stateful-worker", 2ULL * 1024 * 1024 * 1024)); + + bool test_done = false; + + w.run([&]() -> et::task<> { + // Compile first. + worker::CompileParams cp; + cp.path = src.path; + cp.version = 1; + cp.text = "int memlimit_var = 42;\n"; + cp.directory = "/tmp"; + cp.arguments = make_args(src.path); + + auto cr = co_await w.peer->send_request(cp); + EXPECT_TRUE(cr.has_value()); + + // Feature request should work after compilation. + worker::HoverParams hp; + hp.path = src.path; + hp.offset = 4; // 'memlimit_var' + + auto result = co_await w.peer->send_request(hp); + EXPECT_TRUE(result.has_value()); + + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +}; // TEST_SUITE(StatefulWorker) + +} // namespace + +} // namespace clice::testing diff --git a/tests/unit/server/stateless_worker_tests.cpp b/tests/unit/server/stateless_worker_tests.cpp new file mode 100644 index 00000000..58a2101e --- /dev/null +++ b/tests/unit/server/stateless_worker_tests.cpp @@ -0,0 +1,257 @@ +#include +#include + +#include "test/test.h" +#include "eventide/serde/bincode/bincode.h" +#include "eventide/serde/serde/raw_value.h" +#include "server/protocol.h" +#include "server/worker_test_helpers.h" + +namespace clice::testing { + +namespace { + +namespace et = eventide; + +// ============================================================================ +// Bincode Serialization Tests +// ============================================================================ + +TEST_SUITE(BincodeRoundTrip) { + +TEST_CASE(CompileParamsRoundTrip) { + namespace bincode = eventide::serde::bincode; + + worker::CompileParams params; + params.path = "/tmp/test.cpp"; + params.version = 1; + params.text = "int main() { return 0; }"; + params.directory = "/tmp"; + params.arguments = {"clang++", "-c", "test.cpp"}; + params.pch = {"", 0}; + params.pcms = {}; + + auto bytes = bincode::to_bytes(params); + ASSERT_TRUE(bytes.has_value()); + + worker::CompileParams result; + auto status = + bincode::from_bytes(std::span(bytes->data(), bytes->size()), result); + ASSERT_TRUE(status.has_value()); + + EXPECT_EQ(result.path, params.path); + EXPECT_EQ(result.version, params.version); + EXPECT_EQ(result.text, params.text); + EXPECT_EQ(result.directory, params.directory); + EXPECT_EQ(result.arguments.size(), params.arguments.size()); +} + +TEST_CASE(CompileResultRoundTrip) { + namespace bincode = eventide::serde::bincode; + + worker::CompileResult result; + result.version = 1; + result.diagnostics = {}; // empty + result.memory_usage = 0; + + auto bytes = bincode::to_bytes(result); + ASSERT_TRUE(bytes.has_value()); + + worker::CompileResult decoded; + auto status = + bincode::from_bytes(std::span(bytes->data(), bytes->size()), decoded); + ASSERT_TRUE(status.has_value()); + EXPECT_EQ(decoded.version, result.version); +} + +}; // TEST_SUITE(BincodeRoundTrip) + +// ============================================================================ +// StatelessWorker Tests +// ============================================================================ + +TEST_SUITE(StatelessWorker) { + +TEST_CASE(SpawnAndExit) { + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateless-worker")); + + // Close stdin pipe to signal worker to exit. + w.peer->close_output(); + w.loop.schedule(w.peer->run()); + w.loop.run(); +} + +TEST_CASE(BuildPCHRequest) { + TempFile hdr("test_pch.h", "#pragma once\nint pch_global = 42;\n"); + + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateless-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + worker::BuildPCHParams params; + params.file = hdr.path; + params.directory = "/tmp"; + params.arguments = + {"clang++", "-resource-dir", fs::resource_dir, "-x", "c++-header", hdr.path}; + params.content = "#pragma once\nint pch_global = 42;\n"; + + auto result = co_await w.peer->send_request(params); + EXPECT_TRUE(result.has_value()); + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(IndexRequest) { + TempFile src("test_index.cpp", "int indexed_var = 1;\n"); + + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateless-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + worker::IndexParams params; + params.file = src.path; + params.directory = "/tmp"; + params.arguments = make_args(src.path); + + auto result = co_await w.peer->send_request(params); + EXPECT_TRUE(result.has_value()); + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +}; // TEST_SUITE(StatelessWorker) + +// ============================================================================ +// StatelessWorker Extended Tests +// ============================================================================ + +TEST_SUITE(StatelessWorkerExtended) { + +TEST_CASE(BuildPCMRequest) { + TempFile src("test_module.cppm", + "export module test_module;\nexport int module_func() { return 1; }\n"); + + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateless-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + worker::BuildPCMParams params; + params.file = src.path; + params.directory = "/tmp"; + params.arguments = + {"clang++", "-resource-dir", fs::resource_dir, "-std=c++20", "--precompile", src.path}; + params.module_name = "test_module"; + + auto result = co_await w.peer->send_request(params); + EXPECT_TRUE(result.has_value()); + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(CompletionRequest) { + std::string text = "int foo = 1;\nint bar = fo"; + TempFile src("completion_test.cpp", text); + + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateless-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + worker::CompletionParams params; + params.path = src.path; + params.version = 1; + params.text = text; + params.directory = "/tmp"; + params.arguments = make_args(src.path); + params.offset = 25; // after "fo" in "int bar = fo" (13 + 12) + + auto result = co_await w.peer->send_request(params); + EXPECT_TRUE(result.has_value()); + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(SignatureHelpRequest) { + std::string text = "void foo(int a, int b) {}\nint main() { foo("; + TempFile src("sighelp_test.cpp", text); + + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateless-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + worker::SignatureHelpParams params; + params.path = src.path; + params.version = 1; + params.text = text; + params.directory = "/tmp"; + params.arguments = make_args(src.path); + params.offset = 45; // after "foo(" (26 + 19) + + auto result = co_await w.peer->send_request(params); + EXPECT_TRUE(result.has_value()); + // Should return signature help for foo(int a, int b). + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +TEST_CASE(MultipleStatelessRequests) { + std::vector> files; + for(int i = 0; i < 3; i++) { + auto text = "int idx_var_" + std::to_string(i) + " = " + std::to_string(i) + ";\n"; + files.push_back( + std::make_unique("multi_index_" + std::to_string(i) + ".cpp", text)); + } + + WorkerHandle w; + ASSERT_TRUE(w.spawn("stateless-worker")); + + bool test_done = false; + + w.run([&]() -> et::task<> { + // Send multiple index requests to test stateless worker handles them sequentially. + for(int i = 0; i < 3; i++) { + worker::IndexParams params; + params.file = files[i]->path; + params.directory = "/tmp"; + params.arguments = make_args(files[i]->path); + + auto result = co_await w.peer->send_request(params); + EXPECT_TRUE(result.has_value()); + } + test_done = true; + w.peer->close_output(); + }); + + ASSERT_TRUE(test_done); +} + +}; // TEST_SUITE(StatelessWorkerExtended) + +} // namespace + +} // namespace clice::testing diff --git a/tests/unit/server/worker_test_helpers.h b/tests/unit/server/worker_test_helpers.h new file mode 100644 index 00000000..316b5b67 --- /dev/null +++ b/tests/unit/server/worker_test_helpers.h @@ -0,0 +1,146 @@ +#pragma once + +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#endif + +#include "eventide/async/async.h" +#include "eventide/ipc/peer.h" +#include "eventide/ipc/transport.h" +#include "server/protocol.h" +#include "support/filesystem.h" + +namespace clice::testing { + +namespace { + +/// Ignore SIGPIPE so broken pipes from exited workers don't kill the test binary. +struct SigpipeGuard { + SigpipeGuard() { +#ifndef _WIN32 + std::signal(SIGPIPE, SIG_IGN); +#endif + } +}; + +static SigpipeGuard sigpipe_guard; + +namespace et = eventide; + +/// Resolve path to the clice binary for spawning workers. +inline std::string clice_binary() { + auto resource_dir = fs::resource_dir; + // resource_dir is /lib/clang/... + // clice binary is at /bin/clice + auto build_dir = llvm::sys::path::parent_path( + llvm::sys::path::parent_path(llvm::sys::path::parent_path(resource_dir))); + llvm::SmallString<256> path(build_dir); + llvm::sys::path::append(path, "bin", "clice"); + return std::string(path); +} + +/// RAII temporary file: writes content to disk, removes on destruction. +struct TempFile { + std::string path; + + TempFile(const std::string& name, const std::string& content) { + llvm::SmallString<256> tmp_dir; + llvm::sys::path::system_temp_directory(true, tmp_dir); + llvm::sys::path::append(tmp_dir, "clice_test_" + name); + path = std::string(tmp_dir); + std::ofstream ofs(path); + ofs << content; + } + + ~TempFile() { + std::remove(path.c_str()); + } + + std::string uri() const { + return "file://" + path; + } + + TempFile(const TempFile&) = delete; + TempFile& operator=(const TempFile&) = delete; +}; + +/// Build compile arguments for a source file, including -resource-dir. +inline std::vector make_args(const std::string& file_path, + const std::string& extra = "") { + std::vector args = + {"clang++", "-fsyntax-only", "-resource-dir", fs::resource_dir, "-c", file_path}; + if(!extra.empty()) { + args.insert(args.begin() + 1, extra); + } + return args; +} + +/// Helper: spawn a worker process and return a BincodePeer connected to it. +struct WorkerHandle { + et::event_loop loop; + et::process proc{}; + std::unique_ptr transport; + std::unique_ptr peer; + int stderr_fd = -1; + + bool spawn(const std::string& mode, std::uint64_t memory_limit = 0) { + auto binary = clice_binary(); + +#ifndef _WIN32 + // Redirect worker stderr to a temp file for debugging. + std::string stderr_path = "/tmp/clice_worker_stderr_" + mode + ".log"; + stderr_fd = ::open(stderr_path.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); +#endif + + et::process::options opts; + opts.file = binary; + opts.args = {binary, "--mode", mode}; + if(memory_limit > 0) { + opts.args.push_back("--worker-memory-limit"); + opts.args.push_back(std::to_string(memory_limit)); + } + opts.streams = { + et::process::stdio::pipe(true, false), // stdin: child reads + et::process::stdio::pipe(false, true), // stdout: child writes + stderr_fd >= 0 ? et::process::stdio::from_fd(stderr_fd) : et::process::stdio::ignore(), + }; + + auto result = et::process::spawn(opts, loop); + if(!result) { +#ifndef _WIN32 + if(stderr_fd >= 0) + ::close(stderr_fd); +#endif + return false; + } + + auto& spawn = *result; + transport = std::make_unique(std::move(spawn.stdout_pipe), + std::move(spawn.stdin_pipe)); + peer = std::make_unique(loop, std::move(transport)); + proc = std::move(spawn.proc); +#ifndef _WIN32 + if(stderr_fd >= 0) + ::close(stderr_fd); +#endif + return true; + } + + /// Run a coroutine on the event loop and return when it completes. + template + void run(F&& coro_factory) { + loop.schedule(peer->run()); + loop.schedule(coro_factory()); + loop.run(); + } +}; + +} // namespace + +} // namespace clice::testing diff --git a/tests/unit/syntax/scan_tests.cpp b/tests/unit/syntax/scan_tests.cpp index 05f9952e..4827a169 100644 --- a/tests/unit/syntax/scan_tests.cpp +++ b/tests/unit/syntax/scan_tests.cpp @@ -158,7 +158,7 @@ int x = 1; )"); auto args = std::vector{"clang++", "-std=c++20", main_path.c_str()}; - auto results = scan_fuzzy(args, TestVFS::root(), true, {}, nullptr, vfs); + auto results = scan_fuzzy(args, TestVFS::root(), {}, nullptr, vfs); auto main_it = find_by_substr(results, "main.cpp"); ASSERT_TRUE(main_it != results.end()); @@ -182,7 +182,7 @@ TEST_CASE(FuzzyConditionalTracking) { vfs->add("after.h"); auto args = std::vector{"clang++", "-std=c++20", main_path.c_str()}; - auto results = scan_fuzzy(args, TestVFS::root(), true, {}, nullptr, vfs); + auto results = scan_fuzzy(args, TestVFS::root(), {}, nullptr, vfs); auto main_it = find_by_substr(results, "main.cpp"); ASSERT_TRUE(main_it != results.end()); @@ -206,7 +206,7 @@ TEST_CASE(FuzzyNotFound) { vfs->add("also_exists.h"); auto args = std::vector{"clang++", "-std=c++20", main_path.c_str()}; - auto results = scan_fuzzy(args, TestVFS::root(), true, {}, nullptr, vfs); + auto results = scan_fuzzy(args, TestVFS::root(), {}, nullptr, vfs); auto main_it = find_by_substr(results, "main.cpp"); ASSERT_TRUE(main_it != results.end()); @@ -234,7 +234,7 @@ int b = 1; )"); auto args = std::vector{"clang++", "-std=c++20", main_path.c_str()}; - auto results = scan_fuzzy(args, TestVFS::root(), true, {}, nullptr, vfs); + auto results = scan_fuzzy(args, TestVFS::root(), {}, nullptr, vfs); // main.cpp includes a.h auto main_it = find_by_substr(results, "main.cpp"); @@ -265,13 +265,13 @@ int shared = 1; SharedScanCache cache; auto args1 = std::vector{"clang++", "-std=c++20", main_path.c_str()}; - auto results1 = scan_fuzzy(args1, TestVFS::root(), true, {}, &cache, vfs); + auto results1 = scan_fuzzy(args1, TestVFS::root(), {}, &cache, vfs); // shared.h should be cached after first scan. EXPECT_FALSE(cache.entries.empty()); auto args2 = std::vector{"clang++", "-std=c++20", other_path.c_str()}; - auto results2 = scan_fuzzy(args2, TestVFS::root(), true, {}, &cache, vfs); + auto results2 = scan_fuzzy(args2, TestVFS::root(), {}, &cache, vfs); // Both scans should find includes. ASSERT_TRUE(find_by_substr(results1, "main.cpp") != results1.end()); @@ -285,7 +285,7 @@ TEST_CASE(FuzzyWithContent) { vfs->add("header.h"); auto args = std::vector{"clang++", "-std=c++20", main_path.c_str()}; - auto results = scan_fuzzy(args, TestVFS::root(), true, R"(#include "header.h")", nullptr, vfs); + auto results = scan_fuzzy(args, TestVFS::root(), R"(#include "header.h")", nullptr, vfs); auto main_it = find_by_substr(results, "main.cpp"); ASSERT_TRUE(main_it != results.end()); @@ -308,7 +308,7 @@ int x = 1; )"); auto args = std::vector{"clang++", "-std=c++20", main_path.c_str()}; - auto result = scan_precise(args, TestVFS::root(), true, {}, nullptr, vfs); + auto result = scan_precise(args, TestVFS::root(), {}, nullptr, vfs); ASSERT_EQ(result.includes.size(), 1u); EXPECT_FALSE(result.includes[0].not_found); @@ -331,7 +331,7 @@ TEST_CASE(PreciseConditionalWithDefine) { vfs->add("bar.h"); auto args = std::vector{"clang++", "-std=c++20", main_path.c_str()}; - auto result = scan_precise(args, TestVFS::root(), true, {}, nullptr, vfs); + auto result = scan_precise(args, TestVFS::root(), {}, nullptr, vfs); // Precise mode evaluates conditionals: only foo.h should be included. ASSERT_EQ(result.includes.size(), 1u); @@ -346,7 +346,7 @@ TEST_CASE(PreciseWithContent) { vfs->add("header.h"); auto args = std::vector{"clang++", "-std=c++20", main_path.c_str()}; - auto result = scan_precise(args, TestVFS::root(), true, R"(#include "header.h")", nullptr, vfs); + auto result = scan_precise(args, TestVFS::root(), R"(#include "header.h")", nullptr, vfs); ASSERT_EQ(result.includes.size(), 1u); EXPECT_FALSE(result.includes[0].not_found); diff --git a/tests/unit/test/tester.cpp b/tests/unit/test/tester.cpp index 9e4612b8..ef871999 100644 --- a/tests/unit/test/tester.cpp +++ b/tests/unit/test/tester.cpp @@ -18,7 +18,6 @@ void Tester::prepare(llvm::StringRef standard) { options.query_toolchain = true; options.suppress_logging = true; - params.arguments_from_database = true; params.arguments = database.lookup(src_path, options).arguments; for(auto& [file, source]: sources.all_files) { @@ -57,7 +56,6 @@ bool Tester::compile_with_pch(llvm::StringRef standard) { options.query_toolchain = true; options.suppress_logging = true; - params.arguments_from_database = true; params.arguments = database.lookup(src_path, options).arguments; auto pch_path = fs::createTemporaryFile("clice", "pch"); diff --git a/tests/unit/test/tester.h b/tests/unit/test/tester.h index 9d2f292e..484e8dc5 100644 --- a/tests/unit/test/tester.h +++ b/tests/unit/test/tester.h @@ -7,7 +7,7 @@ #include "test/test.h" #include "compile/command.h" #include "compile/compilation.h" -#include "eventide/language/protocol.h" +#include "eventide/ipc/lsp/protocol.h" #include "support/logging.h" namespace clice::testing { diff --git a/tests/unit/unit_tests.cc b/tests/unit/unit_tests.cc index 2c68b808..67146c8c 100644 --- a/tests/unit/unit_tests.cc +++ b/tests/unit/unit_tests.cc @@ -1,25 +1,17 @@ +#include #include -#include "eventide/zest/runner.h" +#include "eventide/deco/macro.h" +#include "eventide/deco/runtime.h" +#include "eventide/zest/zest.h" #include "support/filesystem.h" namespace { -std::string_view parse_filter(int argc, const char** argv) { - constexpr std::string_view prefix = "--test-filter="; - for(int i = 1; i < argc; ++i) { - std::string_view arg = argv[i]; - if(arg.starts_with(prefix)) { - return arg.substr(prefix.size()); - } - - if(arg == "--test-filter" && i + 1 < argc) { - return argv[i + 1]; - } - } - - return {}; -} +struct TestOptions { + DecoKV(names = {"--test-filter"}; help = "Filter tests by name"; required = false;) + test_filter; +}; } // namespace @@ -27,5 +19,14 @@ int main(int argc, const char** argv) { if(auto result = clice::fs::init_resource_dir(argv[0]); !result) { return 1; } - return eventide::zest::Runner::instance().run_tests(parse_filter(argc, argv)); + + auto args = deco::util::argvify(argc, argv); + auto parsed = deco::cli::parse(args); + + std::string_view filter = {}; + if(parsed.has_value() && parsed->options.test_filter.has_value()) { + filter = *parsed->options.test_filter; + } + + return eventide::zest::Runner::instance().run_tests(filter); }