From f65876903c012f94999d06f542466d2d58cc5be5 Mon Sep 17 00:00:00 2001 From: ykiko Date: Tue, 9 Sep 2025 02:03:59 +0800 Subject: [PATCH] Improve integration test (#246) --- .github/workflows/check-format.yml | 17 +- .github/workflows/cmake.yml | 4 +- CMakeLists.txt | 2 +- docs/en/dev/build.md | 47 +---- docs/en/dev/contribution.md | 2 +- docs/en/dev/test-and-debug.md | 84 +++++++++ docs/zh/dev/build.md | 45 +---- docs/zh/dev/contribution.md | 2 +- docs/zh/dev/test-and-debug.md | 84 +++++++++ include/Async/Network.h | 4 +- src/Async/Network.cpp | 4 +- src/Driver/clice.cc | 72 ++++--- tests/conftest.py | 76 +++++--- tests/fixtures/client.py | 3 +- tests/fixtures/transport.py | 291 ++++++++++++++--------------- 15 files changed, 426 insertions(+), 311 deletions(-) create mode 100644 docs/en/dev/test-and-debug.md create mode 100644 docs/zh/dev/test-and-debug.md diff --git a/.github/workflows/check-format.yml b/.github/workflows/check-format.yml index 83f87c0f..6bc35eb2 100644 --- a/.github/workflows/check-format.yml +++ b/.github/workflows/check-format.yml @@ -3,25 +3,10 @@ name: format on: push: branches: [main] - paths: - - ".clang-format" - - ".pre-commit-config.yaml" - - "**/*.py" - - "pyproject.toml" - - "include/**" - - "src/**" - - "tests/**" pull_request: branches: [main] - paths: - - ".clang-format" - - ".pre-commit-config.yaml" - - "**/*.py" - - "pyproject.toml" - - "include/**" - - "src/**" - - "tests/**" + jobs: check: diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index 22085624..7db0d217 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -91,10 +91,10 @@ jobs: if: matrix.os == 'windows-2025' run: | ./build/bin/unit_tests.exe --test-dir="./tests/data" --resource-dir="./.llvm/lib/clang/20" - uv run pytest -s --log-cli-level=INFO tests/integration --executable=./build/bin/clice.exe --resource-dir="./build/lib/clang/20" + uv run pytest -s --log-cli-level=INFO tests/integration --executable=./build/bin/clice.exe - name: Run tests if: matrix.os == 'ubuntu-24.04' || matrix.os == 'macos-15' run: | ./build/bin/unit_tests --test-dir="./tests/data" --resource-dir="./.llvm/lib/clang/20" - uv run pytest -s --log-cli-level=INFO tests/integration --executable=./build/bin/clice --resource-dir="./build/lib/clang/20" + uv run pytest -s --log-cli-level=INFO tests/integration --executable=./build/bin/clice diff --git a/CMakeLists.txt b/CMakeLists.txt index 6f3926fb..e093caeb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,7 +58,7 @@ if (MSVC) # Remove any RTTI or exception enabling flags from CMAKE_CXX_FLAGS string(REPLACE "/EHsc" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") string(REPLACE "/GR" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") - + # Fix MSVC Non-standard preprocessor caused error C1189 # While compiling Command.cpp, MSVC won't expand Options macro correctly # Output: D:\Desktop\code\clice\build\.packages\l\llvm\20.1.5\cc2aa9f1d09a4b71b6fa3bf0011f6387\include\clang/Driver/Options.inc(3590): error C2365: “clang::driver::options::OPT_”: redefinition; previous definition was 'enumerator' diff --git a/docs/en/dev/build.md b/docs/en/dev/build.md index 46e0bdd9..5fab7bb4 100644 --- a/docs/en/dev/build.md +++ b/docs/en/dev/build.md @@ -77,12 +77,13 @@ You can also refer to llvm's official build tutorial [Building LLVM with CMake]( ### GCC Toolchain -clice requires `GCC libstdc++ >= 14`. You could use a different GCC toolchain and also link statically against its `libstdc++`: +clice requires GCC libstdc++ >= 14. You could use a different GCC toolchain and also link statically against its libstdc++: ```bash cmake .. -DCMAKE_C_FLAGS="--gcc-toolchain=/usr/local/gcc-14.3.0/" \ -DCMAKE_CXX_FLAGS="--gcc-toolchain=/usr/local/gcc-14.3.0/" \ -DCMAKE_EXE_LINKER_FLAGS="-static-libgcc -static-libstdc++" +``` ## Building @@ -113,50 +114,6 @@ $ xmake build --all > --llvm is optional. If not specified, xmake will automatically download our precompiled binary -## Run Tests - -clice has two forms of tests: unit tests and integration tests. - -- Run unit tests: - -```bash -$ ./build/bin/unit_tests --test-dir="./tests/data" --resource-dir="/lib/clang/20" -``` - -Or, run unit tests through xmake: - -```bash -$ xmake run --verbose unit_tests -``` - -- Run integration tests: - -We recommend using [uv](https://github.com/astral-sh/uv) to manage Python dependencies and versions. If you don't want to download uv, please refer to `pyproject.toml` to download the required Python version and dependencies. - -```bash -$ pytest -s --log-cli-level=INFO tests/integration --executable=./build/bin/clice --resource-dir="/lib/clang/20" -``` - -> resource-dir is clang's built-in header file folder - -Or, if you use xmake as the build system, you can directly run tests through xmake: - -```shell -$ xmake test --verbose -$ xmake test --verbose integration_tests/default -``` - -Or, if you use xmake build the project and do not have uv installed, you can use the following script: - -```bash -$ pip install pytest pytest-asyncio -$ xmake f -m debug && xmake build unit_tests - -$ pytest -s --log-cli-level=INFO tests/integration \ - --executable=./build/linux/x86_64/debug/clice \ - --resource-dir=./build/linux/x86_64/debug/lib/clang/20/ -``` - ## Building Docker Image Use the following command to build docker image: diff --git a/docs/en/dev/contribution.md b/docs/en/dev/contribution.md index 2536e37f..380f36d4 100644 --- a/docs/en/dev/contribution.md +++ b/docs/en/dev/contribution.md @@ -2,7 +2,7 @@ We welcome any contributions! -Please refer to [build](./build.md) to build clice. +Please refer to [build](./build.md) to build clice, refer to [test and debug](./test-and-debug.md) to test and debug clice. ## Code Style diff --git a/docs/en/dev/test-and-debug.md b/docs/en/dev/test-and-debug.md new file mode 100644 index 00000000..01bfdd31 --- /dev/null +++ b/docs/en/dev/test-and-debug.md @@ -0,0 +1,84 @@ +# Test and Debug + +## Run Tests + +clice has two types of tests: unit tests and integration tests. + +- Run unit tests + +```bash +$ ./build/bin/unit_tests --test-dir="./tests/data" +``` + +- Run integration tests + +We use pytest to run integration tests. Please refer to `pyproject.toml` to install the required Python libraries. + +```bash +$ pytest -s --log-cli-level=INFO tests/integration --executable=./build/bin/clice +``` + +If you use xmake as your build system, you can run the tests directly with xmake: + +```shell +$ xmake run --verbose unit_tests +$ xmake test --verbose integration_tests/default +``` + +## Debug + +If you want to attach a debugger to clice for debugging, it is recommended to first start clice in socket mode independently, and then connect the client to it. + +```shell +$ ./build/bin/clice --mode=socket --port=50051 +``` + +After the server starts, you can connect a client to the server in the following two ways: + +- Connect by running a specific test with pytest + +You can run a single integration test case to connect to a running clice instance. This is very useful for reproducing and debugging specific scenarios. + +```shell +$ pytest -s --log-cli-level=INFO tests/integration/test_file_operation.py::test_did_open --mode=socket --port=50051 +``` + +- Use VS Code for practical testing + +You can also connect to a running clice service by configuring the clice-vscode extension, allowing you to debug in a real-world usage scenario. + +1. Download the [clice-vscode](https://marketplace.visualstudio.com/items?itemName=ykiko.clice-vscode) extension from the Marketplace. + +2. Configure `settings.json`: Create a `.vscode/settings.json` file in your project's root directory and add the following content: + + ```jsonc + { + // Point this to the clice binary you downloaded. + "clice.executable": "/path/to/your/clice/executable", + + // Enable socket mode. + "clice.mode": "socket", + "clice.port": 50051, + + // Optional: Set this to an empty string to turn off the clangd. + "clangd.path": "", + } + ``` + +3. Reload Window: After modifying the configuration, execute the `Developer: Reload Window` command in VS Code for the settings to take effect. The extension will automatically connect to the clice instance listening on port 50051. + + +If you need to modify or debug the clice-vscode extension itself, follow these steps: + +1. Clone and install dependencies: + ```shell + $ git clone https://github.com/clice-io/clice-vscode + $ cd clice-vscode + $ npm install + ``` + +2. Open the extension project with VS Code: Open the `clice-vscode` folder in a new VS Code window. + +3. Create debug configuration: In the `clice-vscode` project, also create a `.vscode/settings.json` file with the same content as above. + +4. Press `F5`. This will launch an [Extension Development Host] window. This is a new VS Code window with your local clice-vscode extension code loaded. Open your C++ project in this new window, and it should automatically connect to clice. diff --git a/docs/zh/dev/build.md b/docs/zh/dev/build.md index c8458ae4..a905790f 100644 --- a/docs/zh/dev/build.md +++ b/docs/zh/dev/build.md @@ -77,13 +77,13 @@ $ python3 /scripts/build-llvm-libs.py debug ### GCC Toolchain -clice 要求 `GCC libstdc++ >= 14` 。以下命令使用不同的 GCC 工具链并静态链接其 `libstdc++`: +clice 要求 GCC libstdc++ >= 14。以下命令使用不同的 GCC 工具链并静态链接其 libstdc++: ```bash cmake .. -DCMAKE_C_FLAGS="--gcc-toolchain=/usr/local/gcc-14.3.0/" \ -DCMAKE_CXX_FLAGS="--gcc-toolchain=/usr/local/gcc-14.3.0/" \ -DCMAKE_EXE_LINKER_FLAGS="-static-libgcc -static-libstdc++" - +``` ## Building @@ -114,48 +114,7 @@ $ xmake build --all > --llvm 是可选的,如果不指定的话,xmake 会自动下载我们编译好的预编译二进制 -## Run Tests -clice 有两种形式的测试,单元测试和集成测试。 - -- 运行单元测试 - -```bash -$ ./build/bin/unit_tests --test-dir="./tests/data" --resource-dir="/lib/clang/20" -``` - -或者, 使用 xmake 启动单元测试: -```bash -$ xmake run --verbose unit_tests -``` - -- 运行集成测试 - -我们推荐使用 [uv](https://github.com/astral-sh/uv) 管理 python 依赖和版本。如果不想下载 uv,请参考 `pyproject.toml` 下载所需的 python 版本和依赖。 - -```bash -$ pytest -s --log-cli-level=INFO tests/integration --executable=./build/bin/clice --resource-dir="/lib/clang/20" -``` - -> resource-dir 是 clang 的内置头文件文件夹 - -如果你使用 xmake 作为构建系统,可以直接通过 xmake 运行测试: - -```shell -$ xmake test --verbose -$ xmake test --verbose integration_tests/default -``` - -在使用 xmake 构建和不使用 uv 的情况下, 启动 debug 模式的测试: - -```shell -$ pip install pytest pytest-asyncio -$ xmake f -m debug && xmake build unit_tests - -$ pytest -s --log-cli-level=INFO tests/integration \ - --executable=./build/linux/x86_64/debug/clice \ - --resource-dir=./build/linux/x86_64/debug/lib/clang/20/ -``` ## Building Docker Image 使用以下命令构建 docker 镜像: diff --git a/docs/zh/dev/contribution.md b/docs/zh/dev/contribution.md index e69c60fc..1edc240f 100644 --- a/docs/zh/dev/contribution.md +++ b/docs/zh/dev/contribution.md @@ -2,7 +2,7 @@ 我们欢迎任何贡献! -请参考 [build](./build.md) 来构建 clice +请参考 [build](./build.md) 来构建 clice,参考 [test and debug](./test-and-debug.md) 来测试和调试 clice。 ## Code Style diff --git a/docs/zh/dev/test-and-debug.md b/docs/zh/dev/test-and-debug.md new file mode 100644 index 00000000..0ab43069 --- /dev/null +++ b/docs/zh/dev/test-and-debug.md @@ -0,0 +1,84 @@ +# Test and Debug + +## Run Tests + +clice 有两种形式的测试,单元测试和集成测试。 + +- 运行单元测试 + +```bash +$ ./build/bin/unit_tests --test-dir="./tests/data" +``` + +- 运行集成测试 + +我们使用 pytest 来运行集成测试,请参考 `pyproject.toml` 安装依赖的 python 库 + +```bash +$ pytest -s --log-cli-level=INFO tests/integration --executable=./build/bin/clice +``` + +如果你使用 xmake 作为构建系统,可以直接通过 xmake 运行测试: + +```shell +$ xmake run --verbose unit_tests +$ xmake test --verbose integration_tests/default +``` + +## Debug + +如果想在 clice 上附加调试器并进行调试,推荐先单独以 socket 模式启动 clice,然后再将客户端连接到 clice 上 + +```shell +$ ./build/bin/clice --mode=socket --port=50051 +``` + +在服务器启动之后,可以通过以下两种方式启动客户端连接到服务器 + +- 使用 pytest 运行特定测试进行连接 + +你可以运行一个单独的集成测试用例来连接正在运行的 clice。这对于复现和调试特定场景非常有用。 + +```shell +$ pytest -s --log-cli-level=INFO tests/integration/test_file_operation.py::test_did_open --mode=socket --port=50051 +``` + +- 使用 vscode 进行实际的测试 + +你也可以通过配置 clice-vscode 插件来连接正在运行的 clice 服务,从而在实际使用场景中进行调试。 + +1. 在插件市场下载插件 [clice-vscode](https://marketplace.visualstudio.com/items?itemName=ykiko.clice-vscode) + +2. 配置 `settings.json`: 在你的项目根目录下创建 `.vscode/settings.json` 文件,并填入以下内容: + + ```jsonc + { + // Point this to the clice binary you downloaded. + "clice.executable": "/path/to/your/clice/executable", + + // Enable socket mode. + "clice.mode": "socket", + "clice.port": 50051, + + // Optional: Set this to an empty string to turn off the clangd. + "clangd.path": "", + } + ``` + +3. 重新加载窗口:修改配置后,在 vscode 中执行 Developer: Reload Window 命令使配置生效。插件会自动连接到正在 50051 端口监听的 clice。 + + +如果你需要修改或调试 clice-vscode 插件本身,可以按以下步骤操作: + +1. 克隆并安装依赖: + ```shell + $ git clone https://github.com/clice-io/clice-vscode + $ cd clice-vscode + $ npm install + ``` + +2. 使用 vscode 打开插件项目:用一个新的 vscode 窗口打开 clice-vscode 文件夹 + +3. 创建调试配置:在 clice-vscode 项目中,也创建一个 `.vscode/settings.json` 文件,内容与上方相同 + +4. 按下 `F5` 键。这会启动一个【扩展开发宿主】窗口。这是一个加载了你本地 clice-vscode 插件代码的新的 vscode 窗口,在这个新窗口中打开你的 C++ 项目,它应该会自动连接到 clice diff --git a/include/Async/Network.h b/include/Async/Network.h index b777e66f..09ce4098 100644 --- a/include/Async/Network.h +++ b/include/Async/Network.h @@ -15,8 +15,8 @@ using Callback = llvm::unique_function(json::Value)>; /// Listen on stdin/stdout, callback is called when there is a LSP message available. void listen(Callback callback); -/// Listen on the given ip and port, callback is called when there is a LSP message available. -void listen(const char* ip, unsigned int port, Callback callback); +/// Listen on the given host and port, callback is called when there is a LSP message available. +void listen(const char* host, unsigned int port, Callback callback); /// FIXME: Spawn a new process and listen on its stdin/stdout. void spawn(llvm::StringRef path, llvm::ArrayRef args, Callback callback); diff --git a/src/Async/Network.cpp b/src/Async/Network.cpp index 739a64ad..c8d3a962 100644 --- a/src/Async/Network.cpp +++ b/src/Async/Network.cpp @@ -84,7 +84,7 @@ void listen(Callback callback) { uv_check_result(uv_read_start(uv_cast(in), net::on_alloc, net::on_read)); } -void listen(const char* ip, unsigned int port, Callback callback) { +void listen(const char* host, unsigned int port, Callback callback) { static uv_tcp_t server; static uv_tcp_t client; @@ -95,7 +95,7 @@ void listen(const char* ip, unsigned int port, Callback callback) { uv_check_result(uv_tcp_init(async::loop, &client)); struct ::sockaddr_in addr; - uv_check_result(uv_ip4_addr(ip, port, &addr)); + uv_check_result(uv_ip4_addr(host, port, &addr)); uv_check_result(uv_tcp_bind(&server, (const struct ::sockaddr*)&addr, 0)); auto on_connection = [](uv_stream_t* server, int status) { diff --git a/src/Driver/clice.cc b/src/Driver/clice.cc index a0ea3f2b..04641446 100644 --- a/src/Driver/clice.cc +++ b/src/Driver/clice.cc @@ -14,39 +14,60 @@ namespace { static cl::OptionCategory category("clice options"); -cl::opt - mode("mode", - cl::cat(category), - cl::value_desc("pipe|socket|indexer"), - cl::init("pipe"), - cl::desc("The mode of clice, default is pipe, socket is usually used for debugging")); +cl::opt mode{ + "mode", + cl::cat(category), + cl::value_desc("pipe|socket|indexer"), + cl::init("pipe"), + cl::desc("The mode of clice, default is pipe, socket is usually used for debugging"), +}; -cl::opt config_path( +cl::opt host{ + "host", + cl::cat(category), + cl::value_desc("str"), + cl::init("127.0.0.1"), + cl::desc("The host to connect to (default: 127.0.0.1)"), +}; + +cl::opt port{ + "port", + cl::cat(category), + cl::value_desc("unsigned int"), + cl::init(50051), + cl::desc("The port to connect to"), +}; + +cl::opt config_path{ "config", cl::cat(category), cl::value_desc("path"), cl::desc( - "The path of the clice config file, if not specified, the default config will be used")); + "The path of the clice config file, if not specified, the default config will be used"), +}; -cl::opt resource_dir( +cl::opt resource_dir{ "resource-dir", cl::cat(category), cl::value_desc("path"), - cl::desc(R"(The path of the clang resource directory, default is "../../lib/clang/version")")); + cl::desc(R"(The path of the clang resource directory, default is "../../lib/clang/version")"), +}; -static cl::OptionCategory category_log{"clice logging options"}; +cl::opt log_color{ + "log-color", + cl::cat(category), + cl::value_desc("always|auto|never"), + cl::init("auto"), + cl::desc("When to use terminal colors, default is auto"), +}; -cl::opt log_color("log-color", - cl::cat(category_log), - cl::value_desc("always|auto|never"), - cl::init("auto"), - cl::desc("When to use terminal colors, default is auto")); - -cl::opt log_level("log-level", - cl::cat(category_log), - cl::value_desc("trace|debug|info|warn|fatal"), - cl::init("info"), - cl::desc("The log level, default is info")); +cl::opt log_level{ + "log-level", + cl::cat(category), + cl::value_desc("trace|debug|info|warn|fatal"), + cl::init("info"), + cl::desc("The log level, default is info"), +}; void printVersion(llvm::raw_ostream& os) { os << std::format("clice version: {}\n", clice::config::version) @@ -74,8 +95,7 @@ void init_log() { /// Check the command line arguments and initialize the clice. bool checkArguments(int argc, const char** argv) { /// Hide unrelated options. - std::vector categories = {&category, &category_log}; - cl::HideUnrelatedOptions(categories); + cl::HideUnrelatedOptions(category); // Set version printer and parse command line options cl::SetVersionPrinter(printVersion); @@ -150,8 +170,8 @@ int main(int argc, const char** argv) { async::net::listen(loop); log::info("Server starts listening on stdin/stdout"); } else if(mode == "socket") { - async::net::listen("127.0.0.1", 50051, loop); - log::info("Server starts listening on {}:{}", "127.0.0.1", 50051); + async::net::listen(host.c_str(), port, loop); + log::info("Server starts listening on {}:{}", host.getValue(), port.getValue()); } else if(mode == "indexer") { /// TODO: } else { diff --git a/tests/conftest.py b/tests/conftest.py index ddcd8e95..c763bce4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,34 +1,52 @@ import os import pytest -import logging import pytest_asyncio from pathlib import Path from .fixtures.client import LSPClient -def pytest_addoption(parser): +def pytest_addoption(parser: pytest.Parser): parser.addoption( "--executable", - action="store", + required=False, help="Path to the of the clice executable.", ) + + CONNECTION_MODES = ["pipe", "socket"] + parser.addoption( + "--mode", + type=str, + choices=CONNECTION_MODES, + default="pipe", + help=f"The connection mode to use. Must be one of: {', '.join(CONNECTION_MODES)})", + ) + + parser.addoption( + "--host", + type=str, + default="127.0.0.1", + help="The host to connect to (default: 127.0.0.1)", + ) + + parser.addoption( + "--port", + type=int, + default=50051, + help="The port to connect to", + ) + parser.addoption( "--resource-dir", - action="store", + required=False, help="Path to the of the clang resource directory.", ) @pytest.fixture(scope="session") -def executable(request): +def executable(request) -> Path | None: executable = request.config.getoption("--executable") - if executable is None: - pytest.exit( - "Error: You must specify the 'clice' executable path using " - "'--executable=' in pytest arguments, " - "or configure it in your pytest.ini/conftest.py.", - returncode=64, - ) + if not executable: + return None path = Path(executable) if not path.exists(): @@ -42,8 +60,10 @@ def executable(request): @pytest.fixture(scope="session") -def resource_dir(request): +def resource_dir(request) -> Path | None: path = request.config.getoption("--resource-dir") + if not path: + return None return Path(path).resolve() @@ -54,25 +74,33 @@ def test_data_dir(request): @pytest_asyncio.fixture(scope="function") -async def client(request, executable: Path, resource_dir: Path, test_data_dir: Path): +async def client( + request, executable: Path | None, resource_dir: Path | None, test_data_dir: Path +): + config = request.config + mode = config.getoption("--mode") + cmd = [ str(executable), - "--mode=pipe", - f"--resource-dir={resource_dir}", + f"--mode={mode}", ] + if resource_dir: + cmd.append(f"--resource-dir={resource_dir}") + if hasattr(request, "param") and request.param: if "config_project" in request.param: project_name = request.param["config_project"] config_path = test_data_dir / project_name / "clice.toml" cmd.append(f"--config={config_path}") - lsp_client = LSPClient(cmd) - await lsp_client.start() + client = LSPClient( + cmd, + mode, + config.getoption("--host"), + config.getoption("--port"), + ) - yield lsp_client - - try: - await lsp_client.exit() - except Exception as e: - logging.error(f"Error during LSP client exit: {e}") + await client.start() + yield client + await client.exit() diff --git a/tests/fixtures/client.py b/tests/fixtures/client.py index 07a3247a..506dcd4a 100644 --- a/tests/fixtures/client.py +++ b/tests/fixtures/client.py @@ -9,7 +9,7 @@ class OpeningFile: class LSPClient(LSPTransport): - def __init__(self, commands, mode="stdio", host="127.0.0.1", port=2087): + def __init__(self, commands, mode, host, port): super().__init__(commands, mode, host, port) self.workspace = "" self.opening_files: dict[Path, OpeningFile] = {} @@ -28,7 +28,6 @@ class LSPClient(LSPTransport): async def exit(self): await self.send_notification("exit") - await self.stop() def get_abs_path(self, relative_path: str): return Path(self.workspace, relative_path) diff --git a/tests/fixtures/transport.py b/tests/fixtures/transport.py index 838feb99..0d524ca5 100644 --- a/tests/fixtures/transport.py +++ b/tests/fixtures/transport.py @@ -1,32 +1,38 @@ import json import asyncio import logging -from typing import Any, Callable +from typing import Any, Callable, Coroutine + + +class LSPError(Exception): + pass class LSPTransport: - def __init__(self, commands: list[str], mode="stdio", host="127.0.0.1", port=2087): + 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 - self.reader: asyncio.StreamReader = None - self.writer: asyncio.StreamWriter = None + 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]], Any]] = {} - self.message_queue: asyncio.Queue = asyncio.Queue() + 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() - logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" - ) + self._tasks: set[asyncio.Task] = set() + self._stopping = False async def start(self): - if self.mode == "stdio": - logging.info(f"Starting LSP server via stdio: {self.commands}") + 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, @@ -35,193 +41,184 @@ class LSPTransport: ) self.reader = self.process.stdout self.writer = self.process.stdin - logging.info("LSP server started via stdio.") + self.logger.info(f"LSP server started with PID {self.process.pid}") elif self.mode == "socket": - logging.info( + self.logger.info( f"Connecting to LSP server via socket: {self.host}:{self.port}" ) - # Note: For socket mode, you usually need to start the LSP server externally - # or have it run as a daemon process already. This client will just connect. - try: - self.reader, self.writer = await asyncio.open_connection( - self.host, self.port - ) - logging.info("Connected to LSP server via socket.") - except ConnectionRefusedError: - logging.error( - f"Connection refused: No LSP server listening on {self.host}:{self.port}" - ) - raise - except Exception as e: - logging.error(f"Error connecting via socket: {e}") - raise + 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 'stdio' or 'socket'.") + raise ValueError("Invalid connection mode. Use 'pipe' or 'socket'") - asyncio.create_task(self._read_messages()) - asyncio.create_task(self._process_messages()) - if self.process and self.process.stderr: - asyncio.create_task(self._read_stderr()) + 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.mode == "stdio" and self.process: - return_code = await self.process.wait() - if return_code != 0: - raise RuntimeError("Server exit with error!") + if self._stopping: + return + self._stopping = True + self.logger.info("Stopping LSPTransport") - elif self.mode == "socket" and self.writer: - logging.info("Closing socket connection to LSP server.") - self.writer.close() - await self.writer.wait_closed() - logging.info("Socket connection closed.") + 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 - while True: - line = await self.process.stderr.readline() - if not line: - break - logging.error(f"LSP Server STDERR: {line.decode().strip()}") - - async def _parse_header_line(self, header_line: bytes) -> int | None: - header_line = header_line.strip() - if not header_line: - return None - - if header_line.startswith(b"Content-Length:"): - try: - return int(header_line.split(b":")[1].strip()) - except ValueError: - logging.error(f"Invalid Content-Length header: {header_line.decode()}") - return 0 - if header_line.startswith(b"Content-Type:"): - return 0 - - logging.warning(f"Unknown header: {header_line.decode()}") - return 0 + 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): - content_length = 0 - content_bytes = b"" - try: - while True: - if not self.reader: - logging.info( - "LSP client reader is not available. Exiting _read_messages." - ) + 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 - header_line = await self.reader.readline() - if not header_line: - logging.info( - "LSP server output stream closed. Exiting _read_messages." - ) - break - - parsed_length = await self._parse_header_line(header_line) - - # Empty line means headers end - if parsed_length is None: - if content_length > 0: - content_bytes = await self.reader.readexactly(content_length) - message = json.loads(content_bytes.decode("utf-8")) - await self.message_queue.put(message) - content_length = 0 - continue - - if parsed_length > 0: - content_length = parsed_length - - except asyncio.IncompleteReadError as e: - logging.error(f"Incomplete message read: {e}") - except json.JSONDecodeError as e: - decoded_content_attempt = ( - content_bytes.decode("utf-8", errors="ignore") - if content_bytes - else "N/A" - ) - logging.error( - f"JSON decode error: {e}, Content (attempted): {decoded_content_attempt}" - ) + 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: - logging.error(f"Error reading messages: {e}") + if not self._stopping: + self.logger.error(f"Unexpected error in message reader: {e}") finally: - logging.info("_read_messages task finished.") + if not self._stopping: + asyncio.create_task(self.stop()) async def _handle_response(self, message: dict[str, Any]): - request_id = message["id"] - if request_id not in self.pending_requests: - logging.warning( - f"Received unknown request/response ID: {request_id}, message: {message}" + 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 - future = self.pending_requests.pop(request_id) if "result" in message: future.set_result(message["result"]) elif "error" in message: - future.set_exception(Exception(f"LSP Error: {message['error']}")) + future.set_exception(LSPError(message["error"])) else: future.set_exception( - Exception(f"LSP response missing 'result' or 'error': {message}") + LSPError(f"LSP response missing 'result' or 'error': {message}") ) async def _handle_notification(self, message: dict[str, Any]): method = message["method"] - if method not in self.notification_handlers: - logging.warning( - f"Received unhandled notification: {method}, message: {message}" - ) + handler = self.notification_handlers.get(method) + if not handler: + self.logger.debug(f"Received unhandled notification: {method}") return - try: - await self.notification_handlers[method](message.get("params")) + params = message.get("params") + result = handler(params) + if asyncio.iscoroutine(result): + await result except Exception as e: - logging.error(f"Error in notification handler for {method}: {e}") + self.logger.error(f"Error in notification handler for {method}: {e}") async def _process_messages(self): - message: dict[str, Any] = None - try: while True: message = await self.message_queue.get() - logging.debug(f"Received message: {message}") + 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: - logging.warning(f"Received malformed LSP message: {message}") - + self.logger.warning(f"Received malformed LSP message: {message}") except asyncio.CancelledError: - logging.info("_process_messages task cancelled.") + pass except Exception as e: - logging.error(f"Critical error processing message: {e}, Message: {message}") - finally: - logging.info("_process_messages task finished.") + 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: - logging.error("LSP client writer is not available.") - return + if not self.writer or self.writer.is_closing(): + raise ConnectionError("LSP client writer is not available or closing") - encoded_message = json.dumps(message, ensure_ascii=False).encode("utf-8") - content_length = len(encoded_message) - - header = (f"Content-Length: {content_length}\r\n\r\n").encode("utf-8") + 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(encoded_message) + self.writer.write(body) await self.writer.drain() - logging.debug( - f"Sent message: {message.get('method', 'Unknown Method')} (ID: {message.get('id', 'N/A')})" - ) - except Exception as e: - logging.error(f"Error sending message: {e}, message: {message}") + 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 @@ -234,9 +231,9 @@ class LSPTransport: "method": method, "params": params if params is not None else {}, } - await self._send_message(message) - future = asyncio.Future() + 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( @@ -250,6 +247,8 @@ class LSPTransport: await self._send_message(message) def register_notification_handler( - self, method: str, handler: Callable[[dict[str, Any]], Any] + self, + method: str, + handler: Callable[[dict[str, Any] | None], Coroutine[Any, Any, None] | None], ): self.notification_handlers[method] = handler