Compare commits

...
29 Commits
Author SHA1 Message Date
csh a5c1b2c67b 🔧 build(lsp-server): 构建产物迁出 OneDrive 至 /var/tmp
lsp-server ci / build-and-test (push) Failing after 1s
将默认构建目录从项目内 lsp-server/build/clang-linux 改为跨机器
固定绝对路径 /var/tmp/tsl-devkit,避免 OneDrive 同步构建产物。

- .clangd: CompilationDatabase 指向新路径
- conanfile.txt: Linux/交叉编译命令统一到 /var/tmp,补齐 cross 段 /Release
- AGENT_RULES.local.md: 更新构建/验证目录,补充 Windows 交叉编译段
- tech-context.md: 更新运行测试目录
2026-07-14 21:54:05 +08:00
csh 9369b652f8 📝 docs(progress): finish LSP core lifecycle plan 2026-07-14 12:28:46 +08:00
csh 8a429ccd19 test(cli): expect abnormal EOF exit 2026-07-14 12:09:50 +08:00
csh bb44bd1e32 🐛 fix(core): enable syntax diagnostics 2026-07-14 12:09:17 +08:00
csh e122abc305 🗑️ remove(provider): delete staged capability scaffolding 2026-07-14 12:08:42 +08:00
csh 5a02bd10f9 🎨 style(core): normalize lifecycle implementation 2026-07-14 12:08:09 +08:00
csh 12e1a87b2b 🐛 fix(core): convert diagnostics to UTF-16 positions 2026-07-14 09:35:34 +08:00
csh 71b519793a 🐛 fix(core): reject invalid LSP framing 2026-07-14 09:03:47 +08:00
csh 101b69e84f 🐛 fix(core): dispatch cancellable requests asynchronously 2026-07-14 08:56:30 +08:00
csh efb6eae796 🐛 fix(core): return JSON-RPC protocol errors 2026-07-14 08:22:40 +08:00
csh 06a0da5124 🐛 fix(core): enforce strict LSP lifecycle 2026-07-13 21:35:40 +08:00
csh 5edd4b3637 📝 docs(plan): implement strict LSP core lifecycle 2026-07-13 20:48:14 +08:00
csh b7dc52e895 📝 docs(spec): design strict LSP core lifecycle 2026-07-13 20:21:40 +08:00
csh 2f1cc4b640 📝 docs(progress): finish async executor cancellation plan 2026-07-13 16:06:36 +08:00
csh 89b8937697 🐛 fix(async_executor): implement cooperative cancellation 2026-07-13 16:03:56 +08:00
csh edea37202d 📝 docs(plan): implement async executor cancellation 2026-07-12 20:17:33 +08:00
csh 627613f586 📝 docs(spec): design async executor cancellation 2026-07-12 19:48:10 +08:00
csh a042bf2efc 📝 docs(progress): finish UTF-16 coordinate plan 2026-07-12 12:34:44 +08:00
csh aa31ecad18 test(text_coordinates): run cases before exit coverage 2026-07-12 12:32:55 +08:00
csh 61d16d0147 🐛 fix(text_coordinates): convert LSP UTF-16 positions 2026-07-12 12:15:46 +08:00
csh 16986281a7 📝 docs(plan): implement UTF-16 text coordinate conversion 2026-07-12 09:31:49 +08:00
csh 5e84eb428e 📝 docs(spec): design UTF-16 text coordinate conversion 2026-07-12 09:23:54 +08:00
csh 80fca5d11c 📝 docs(progress): finish args parser plan 2026-07-11 22:01:54 +08:00
csh 98c05323cb test(cli): harden startup coverage 2026-07-11 21:58:33 +08:00
csh 100cfc0d0e ♻️ refactor(editors): use fixed stderr logging 2026-07-11 18:27:51 +08:00
csh 49e7c7f424 test(lsp): remove obsolete logger flags 2026-07-11 18:26:14 +08:00
csh 3642aa8b5b ♻️ refactor(cli): validate startup arguments 2026-07-11 18:07:22 +08:00
csh 9416d44d3d 📝 docs(plan): implement args parser startup errors 2026-07-11 14:23:42 +08:00
csh f37cfbd92b 📝 docs(spec): design args parser startup errors 2026-07-11 14:07:04 +08:00
58 changed files with 6099 additions and 1582 deletions
+26 -8
View File
@@ -39,28 +39,46 @@
## 构建与验证
默认 Linux 构建目录:`lsp-server/build/clang-linux/Release`
默认 Linux 构建目录:`/var/tmp/tsl-devkit/clang-linux/Release`
(跨机器固定绝对路径,不在项目内构建)
- 构建前置:
```bash
CONAN_HOME=/tmp/conan-home conan install lsp-server \
CONAN_HOME=/var/tmp/tsl-devkit/conan-home conan install lsp-server \
-pr:h=lsp-server/conan/profiles/linux-x86_64-clang \
-pr:b=lsp-server/conan/profiles/linux-x86_64-clang \
-of lsp-server/build/clang-linux/Release \
-of /var/tmp/tsl-devkit/clang-linux \
--build=missing
cmake -S lsp-server -B lsp-server/build/clang-linux/Release -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=$PWD/lsp-server/build/clang-linux/Release/generators/conan_toolchain.cmake \
cmake -S lsp-server -B /var/tmp/tsl-devkit/clang-linux/Release -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=/var/tmp/tsl-devkit/clang-linux/Release/generators/conan_toolchain.cmake \
-DBUILD_TESTS=ON
```
- AST / 解析改动至少执行:
`ctest --test-dir lsp-server/build/clang-linux/Release -R 'test_ast|test_tree_sitter' --output-on-failure`
`ctest --test-dir /var/tmp/tsl-devkit/clang-linux/Release -R 'test_ast|test_tree_sitter' --output-on-failure`
- Provider / 协议改动至少执行:
`ctest --test-dir lsp-server/build/clang-linux/Release -R 'test_provider|test_lsp_any|request_json' --output-on-failure`
`ctest --test-dir /var/tmp/tsl-devkit/clang-linux/Release -R 'test_provider|test_lsp_any|request_json' --output-on-failure`
- 语义 / 符号改动至少执行:
`ctest --test-dir lsp-server/build/clang-linux/Release -R 'test_semantic|test_symbol' --output-on-failure`
`ctest --test-dir /var/tmp/tsl-devkit/clang-linux/Release -R 'test_semantic|test_symbol' --output-on-failure`
Windows 交叉编译目录:`/var/tmp/tsl-devkit/clang-cross/Release`
(仅出包用,不跑测试,`-DBUILD_TESTS=OFF`
```bash
CONAN_HOME=/var/tmp/tsl-devkit/conan-home conan install lsp-server \
-pr:b=lsp-server/conan/profiles/linux-x86_64-clang \
-pr:h=lsp-server/conan/profiles/windows-x86_64-clang-cross \
-of /var/tmp/tsl-devkit/clang-cross \
--build=missing
cmake -S lsp-server -B /var/tmp/tsl-devkit/clang-cross/Release -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=/var/tmp/tsl-devkit/clang-cross/Release/generators/conan_toolchain.cmake \
-DBUILD_TESTS=OFF
cmake --build /var/tmp/tsl-devkit/clang-cross/Release --target tsl-server
```
## Playbook 对齐规则
@@ -0,0 +1,813 @@
# Args Parser Startup Errors Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refactor argument parsing into a stateless, strictly validated API and make all startup failures exit predictably without contaminating LSP stdout.
**Architecture:** `lsp.utils.args_parser` becomes a pure parser that returns `std::expected<ParseResult, std::string>` and owns only CLI syntax plus help rendering. `lsp.cli.launcher` owns logger initialization and maps argument, logger, and server failures to exit codes 2, 1, and 1. Repository callers are migrated away from the removed stdout/stderr selection flags.
**Tech Stack:** C++23 Modules, `std::expected`, `std::from_chars`, spdlog, CMake/CTest, Python 3 subprocess tests, TypeScript
---
## Plan Meta
- **Plan Group:** args-parser-startup-errors
- **Parent Plan:** none
- **Verification Scope:** parser unit tests, CLI startup tests, LSP JSON/provider tests, VSCode TypeScript compile
- **Verification Gate:** all scoped CTest cases pass, VSCode compiles, and removed flags have no remaining runtime callers
- **Execution Constraints:** `karpathy-guidelines`, `.agents`, `AGENT_RULES.md`, test-driven development
## File Map
- Modify `lsp-server/src/utils/args_parser.cppm`: stateless parsing types, validation, and help rendering.
- Modify `lsp-server/src/cli/launcher.cppm`: logger initialization and staged startup errors.
- Create `lsp-server/test/test_args_parser/CMakeLists.txt`: parser unit-test target.
- Create `lsp-server/test/test_args_parser/main.cc`: parser unit-test entry point.
- Create `lsp-server/test/test_args_parser/test_args_parser.cppm`: parser and help tests.
- Create `lsp-server/test/test_cli_startup.py`: executable-level startup regression tests.
- Modify `lsp-server/test/CMakeLists.txt`: register the new C++ and Python tests.
- Modify `lsp-server/test/run_lsp_json_tests.py`: remove obsolete logger-selection argument.
- Modify `lsp-server/test/test_provider/server_json_test.cppm`: remove obsolete stdio alias.
- Modify `vscode/src/extension.ts`: stop injecting the removed stderr flag.
- Modify `vscode/package.json`: update default server arguments.
- Modify `vscode/README.md`: document current arguments.
- Modify `vim/README.md`: remove obsolete logger-selection examples while leaving interpreter-path expansion out of scope.
### Task 1: Drive the parser and launcher refactor with regression tests
**Files:**
- Create: `lsp-server/test/test_args_parser/CMakeLists.txt`
- Create: `lsp-server/test/test_args_parser/main.cc`
- Create: `lsp-server/test/test_args_parser/test_args_parser.cppm`
- Create: `lsp-server/test/test_cli_startup.py`
- Modify: `lsp-server/test/CMakeLists.txt`
- Modify: `lsp-server/src/utils/args_parser.cppm`
- Modify: `lsp-server/src/cli/launcher.cppm`
- [ ] **Step 1: Add the parser unit-test target and failing tests**
Create `lsp-server/test/test_args_parser/CMakeLists.txt`:
```cmake
cmake_minimum_required(VERSION 4.0)
project(test_args_parser LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPERIMENTAL_CXX_MODULE_DYNDEP 1)
find_package(spdlog CONFIG REQUIRED)
find_package(fmt CONFIG REQUIRED)
add_executable(test_args_parser main.cc test_args_parser.cppm)
if(TARGET std_module)
add_dependencies(test_args_parser std_module)
endif()
target_sources(
test_args_parser
PRIVATE
FILE_SET cxx_modules TYPE CXX_MODULES
BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/../../src
FILES test_args_parser.cppm
../../src/bridge/spdlog.cppm
../../src/utils/args_parser.cppm)
target_compile_definitions(test_args_parser PRIVATE SPDLOG_HEADER_ONLY
FMT_HEADER_ONLY)
target_link_libraries(test_args_parser PRIVATE spdlog::spdlog_header_only
fmt::fmt-header-only)
target_compile_options(
test_args_parser
PRIVATE -Wall -Wextra -Wpedantic
-Wno-import-implementation-partition-unit-in-interface-unit
$<$<CONFIG:Debug>:-g -O0>
$<$<CONFIG:Release>:-O3>)
if(BUILD_TESTS)
add_test(NAME test_args_parser COMMAND $<TARGET_FILE:test_args_parser>)
endif()
```
Create `lsp-server/test/test_args_parser/main.cc`:
```cpp
import lsp.test.args_parser;
int main()
{
return Run();
}
```
Create `lsp-server/test/test_args_parser/test_args_parser.cppm`:
```cpp
module;
export module lsp.test.args_parser;
import spdlog;
import std;
import lsp.utils.args_parser;
namespace
{
void Expect(bool condition, std::string_view message)
{
if (!condition)
throw std::runtime_error(std::string(message));
}
auto Parse(std::initializer_list<std::string_view> arguments)
{
std::vector<std::string> storage{ "tsl-server" };
storage.reserve(arguments.size() + 1);
for (auto argument : arguments)
storage.emplace_back(argument);
std::vector<char*> argv;
argv.reserve(storage.size());
for (auto& argument : storage)
argv.push_back(argument.data());
return lsp::utils::ParseArgs(static_cast<int>(argv.size()), argv.data());
}
void ExpectError(std::initializer_list<std::string_view> arguments,
std::string_view fragment)
{
auto result = Parse(arguments);
Expect(!result.has_value(), "arguments should be rejected");
Expect(result.error().find(fragment) != std::string::npos,
"error should identify the invalid argument");
}
}
export int Run()
{
int failures = 0;
auto RunCase = [&failures](std::string_view name, auto test) {
try
{
test();
std::cout << "[PASS] " << name << '\n';
}
catch (const std::exception& error)
{
++failures;
std::cout << "[FAIL] " << name << ": " << error.what() << '\n';
}
};
RunCase("defaults", [] {
auto result = Parse({});
Expect(result.has_value(), "default arguments should parse");
Expect(result->action == lsp::utils::ParseAction::kRun,
"default action should run");
Expect(result->config.thread_count == 4, "default thread count should be 4");
Expect(result->config.log_level == spdlog::level::info,
"default log level should be info");
});
RunCase("valid values and last occurrence", [] {
auto result = Parse({ "--threads=8", "--threads=16", "--log=debug",
"--log-file=server.log", "--interpreter=/opt/tsl" });
Expect(result.has_value(), "valid arguments should parse");
Expect(result->config.thread_count == 16, "last thread count should win");
Expect(result->config.log_level == spdlog::level::debug,
"debug log level should parse");
Expect(result->config.log_file == "server.log", "log file should parse");
Expect(result->config.interpreter_path == "/opt/tsl",
"interpreter path should parse");
});
RunCase("all log levels", [] {
const std::array levels{
std::pair{ "trace", spdlog::level::trace },
std::pair{ "debug", spdlog::level::debug },
std::pair{ "info", spdlog::level::info },
std::pair{ "warn", spdlog::level::warn },
std::pair{ "error", spdlog::level::err },
std::pair{ "off", spdlog::level::off },
};
for (const auto& [name, level] : levels)
{
const std::string argument = std::string("--log=") + name;
auto result = Parse({ argument });
Expect(result.has_value(), "known log level should parse");
Expect(result->config.log_level == level, "log level should match");
}
});
RunCase("help takes precedence", [] {
auto result = Parse({ "--unknown", "--help", "--threads=bad" });
Expect(result.has_value(), "help should bypass other validation");
Expect(result->action == lsp::utils::ParseAction::kShowHelp,
"help action should be returned");
});
RunCase("thread validation", [] {
Expect(Parse({ "--threads=1" }).has_value(), "one thread should parse");
Expect(Parse({ "--threads=256" }).has_value(), "256 threads should parse");
for (auto argument : { "--threads=", "--threads=0", "--threads=-1",
"--threads=257", "--threads=12junk",
"--threads=18446744073709551616" })
ExpectError({ argument }, "--threads");
});
RunCase("other invalid values", [] {
for (auto argument : { "--log=verbose", "--log-file=", "--interpreter=",
"--log-stdout", "--log-stderr", "--use-stdio",
"--unknown" })
ExpectError({ argument }, "--");
});
RunCase("help formatting", [] {
std::ostringstream output;
lsp::utils::PrintHelp(output, "tsl-server");
const auto text = output.str();
Expect(text.contains("Usage: tsl-server [options]\n\nOptions:\n"),
"help should contain physical line breaks");
Expect(!text.contains("\\n"), "help should not contain escaped newline text");
Expect(!text.contains("--log-stdout"), "removed stdout flag should be absent");
Expect(!text.contains("--log-stderr"), "removed stderr flag should be absent");
Expect(!text.contains("--use-stdio"), "removed stdio alias should be absent");
});
std::cout << "Failures: " << failures << '\n';
return failures == 0 ? 0 : 1;
}
```
Register the directory in `lsp-server/test/CMakeLists.txt` after
`test_scheduler`:
```cmake
if(EXISTS ${CMAKE_CURRENT_LIST_DIR}/test_args_parser/CMakeLists.txt)
add_subdirectory(test_args_parser)
endif()
```
- [ ] **Step 2: Add failing executable-level startup tests**
Create `lsp-server/test/test_cli_startup.py`:
```python
#!/usr/bin/env python3
import argparse
import subprocess
import tempfile
import unittest
from pathlib import Path
SERVER: Path
def run_server(*arguments: str) -> subprocess.CompletedProcess[bytes]:
return subprocess.run(
[str(SERVER), *arguments],
input=b"",
capture_output=True,
check=False,
)
class CliStartupTest(unittest.TestCase):
def test_help_uses_real_newlines(self) -> None:
result = run_server("--help")
self.assertEqual(0, result.returncode)
self.assertIn(b"\n\nOptions:\n", result.stdout)
self.assertNotIn(b"\\n", result.stdout)
def test_invalid_threads_return_argument_error(self) -> None:
for argument in ("--threads=", "--threads=-1", "--threads=12junk"):
with self.subTest(argument=argument):
result = run_server(argument)
self.assertEqual(2, result.returncode)
self.assertIn(b"--threads", result.stderr)
self.assertNotIn(b"TSL-LSP server starting", result.stderr)
def test_removed_stdout_flag_is_rejected(self) -> None:
result = run_server("--log-stdout")
self.assertEqual(2, result.returncode)
self.assertEqual(b"", result.stdout)
def test_default_logger_never_writes_stdout(self) -> None:
result = run_server()
self.assertEqual(0, result.returncode)
self.assertEqual(b"", result.stdout)
self.assertIn(b"TSL-LSP server starting", result.stderr)
def test_log_file_failure_is_controlled(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "missing" / "server.log"
result = run_server(f"--log-file={path}")
self.assertEqual(1, result.returncode)
self.assertIn(b"Failed to initialize logger", result.stderr)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--server", type=Path, required=True)
args, unittest_args = parser.parse_known_args()
SERVER = args.server.resolve()
unittest.main(argv=[__file__, *unittest_args])
```
Register it next to `test_lsp_json` in `lsp-server/test/CMakeLists.txt`:
```cmake
add_test(NAME test_cli_startup
COMMAND ${PYTHON3_EXECUTABLE}
${CMAKE_CURRENT_LIST_DIR}/test_cli_startup.py
--server $<TARGET_FILE:tsl-server>)
```
- [ ] **Step 3: Run the new tests to verify the old implementation fails**
Run:
```bash
cmake -S lsp-server -B lsp-server/build/clang-linux/Release
cmake --build lsp-server/build/clang-linux/Release --target tsl-server
python lsp-server/test/test_cli_startup.py \
--server lsp-server/build/clang-linux/Release/src/tsl-server
cmake --build lsp-server/build/clang-linux/Release --target test_args_parser
```
Expected: the Python suite fails the escaped-newline and controlled-error assertions.
The subsequent parser-test build fails because `ParseArgs`, `ParseAction`, and the
stream-based `PrintHelp` do not exist.
- [ ] **Step 4: Replace the stateful parser with the stateless implementation**
Replace `lsp-server/src/utils/args_parser.cppm` with:
```cpp
module;
export module lsp.utils.args_parser;
import spdlog;
import std;
export namespace lsp::utils
{
struct ServerConfig
{
std::size_t thread_count = 4;
spdlog::level::level_enum log_level = spdlog::level::info;
std::string log_file;
std::string interpreter_path;
};
enum class ParseAction
{
kRun,
kShowHelp,
};
struct ParseResult
{
ParseAction action = ParseAction::kRun;
ServerConfig config;
};
std::expected<ParseResult, std::string> ParseArgs(int argc, char* const argv[]);
void PrintHelp(std::ostream& output, std::string_view program_name);
}
namespace lsp::utils
{
namespace
{
constexpr std::size_t kMinThreadCount = 1;
constexpr std::size_t kMaxThreadCount = 256;
std::optional<spdlog::level::level_enum> ParseLogLevel(std::string_view value)
{
if (value == "trace")
return spdlog::level::trace;
if (value == "debug")
return spdlog::level::debug;
if (value == "info")
return spdlog::level::info;
if (value == "warn")
return spdlog::level::warn;
if (value == "error")
return spdlog::level::err;
if (value == "off")
return spdlog::level::off;
return std::nullopt;
}
}
std::expected<ParseResult, std::string> ParseArgs(int argc, char* const argv[])
{
for (int i = 1; i < argc; ++i)
{
if (std::string_view(argv[i]) == "--help")
return ParseResult{ .action = ParseAction::kShowHelp };
}
ParseResult result;
constexpr std::string_view kLogPrefix = "--log=";
constexpr std::string_view kLogFilePrefix = "--log-file=";
constexpr std::string_view kThreadsPrefix = "--threads=";
constexpr std::string_view kInterpreterPrefix = "--interpreter=";
for (int i = 1; i < argc; ++i)
{
const std::string_view argument = argv[i];
if (argument.starts_with(kLogPrefix))
{
const auto value = argument.substr(kLogPrefix.size());
auto level = ParseLogLevel(value);
if (!level)
return std::unexpected("Invalid value for --log: '" +
std::string(value) + "'");
result.config.log_level = *level;
}
else if (argument.starts_with(kLogFilePrefix))
{
const auto value = argument.substr(kLogFilePrefix.size());
if (value.empty())
return std::unexpected("--log-file requires a non-empty path");
result.config.log_file = value;
}
else if (argument.starts_with(kThreadsPrefix))
{
const auto value = argument.substr(kThreadsPrefix.size());
std::size_t count = 0;
const auto [end, error] =
std::from_chars(value.data(), value.data() + value.size(), count);
if (error != std::errc{} || end != value.data() + value.size() ||
count < kMinThreadCount || count > kMaxThreadCount)
{
return std::unexpected("Invalid value for --threads: '" +
std::string(value) + "' (expected 1..256)");
}
result.config.thread_count = count;
}
else if (argument.starts_with(kInterpreterPrefix))
{
const auto value = argument.substr(kInterpreterPrefix.size());
if (value.empty())
return std::unexpected("--interpreter requires a non-empty path");
result.config.interpreter_path = value;
}
else
{
return std::unexpected("Unknown argument: " + std::string(argument));
}
}
return result;
}
void PrintHelp(std::ostream& output, std::string_view program_name)
{
output << "Usage: " << program_name << " [options]\n\n"
<< "Options:\n"
<< " --help Show this help message\n"
<< " --log=<level> Set log level (trace, debug, info, warn, error, off)\n"
<< " --log-file=<path> Output logs to specified file\n"
<< " --threads=<count> Number of worker threads (1-256)\n"
<< " --interpreter=<path> Custom interpreter path\n";
}
}
```
- [ ] **Step 5: Move logger ownership and staged errors into the launcher**
Replace `lsp-server/src/cli/launcher.cppm` with:
```cpp
module;
export module lsp.cli.launcher;
import spdlog;
import std;
import lsp.core.server;
import lsp.utils.args_parser;
namespace
{
void SetupLogger(const lsp::utils::ServerConfig& config)
{
spdlog::set_pattern("%Y-%m-%d %H:%M:%S.%e [%t] [%^%l%$] %v");
auto logger = config.log_file.empty()
? spdlog::stderr_logger_mt("console_logger")
: spdlog::basic_logger_mt("file_logger", config.log_file);
logger->set_level(config.log_level);
spdlog::set_default_logger(logger);
spdlog::set_level(config.log_level);
}
}
export int Run(int argc, char* argv[])
{
auto parsed = lsp::utils::ParseArgs(argc, argv);
if (!parsed)
{
std::cerr << "[TSL-LSP] Argument error: " << parsed.error() << '\n';
return 2;
}
if (parsed->action == lsp::utils::ParseAction::kShowHelp)
{
lsp::utils::PrintHelp(std::cout, argv[0]);
return 0;
}
const auto& config = parsed->config;
try
{
SetupLogger(config);
}
catch (const std::exception& error)
{
std::cerr << "[TSL-LSP] Failed to initialize logger: " << error.what() << '\n';
return 1;
}
try
{
spdlog::info("TSL-LSP server starting...");
lsp::core::LspServer server(config.thread_count, config.interpreter_path);
server.Run();
}
catch (const std::exception& error)
{
std::cerr << "[TSL-LSP] Server fatal error: " << error.what() << '\n';
spdlog::error("Server fatal error: {}", error.what());
spdlog::shutdown();
return 1;
}
catch (...)
{
std::cerr << "[TSL-LSP] Server unknown fatal error\n";
spdlog::error("Server unknown fatal error");
spdlog::shutdown();
return 1;
}
spdlog::info("TSL-LSP server stopped normally");
spdlog::shutdown();
return 0;
}
```
- [ ] **Step 6: Format, build, and run the new tests**
Run:
```bash
clang-format -i \
lsp-server/src/utils/args_parser.cppm \
lsp-server/src/cli/launcher.cppm \
lsp-server/test/test_args_parser/main.cc \
lsp-server/test/test_args_parser/test_args_parser.cppm
cmake -S lsp-server -B lsp-server/build/clang-linux/Release
cmake --build lsp-server/build/clang-linux/Release \
--target tsl-server test_args_parser
ctest --test-dir lsp-server/build/clang-linux/Release \
-R 'test_args_parser|test_cli_startup' --output-on-failure
```
Expected: both tests pass; malformed arguments return 2, logger creation failure returns
1, and default logs do not appear on stdout.
- [ ] **Step 7: Commit the parser and launcher refactor**
```bash
git add lsp-server/src/utils/args_parser.cppm \
lsp-server/src/cli/launcher.cppm \
lsp-server/test/CMakeLists.txt \
lsp-server/test/test_args_parser \
lsp-server/test/test_cli_startup.py
git commit -m "refactor(cli): validate startup arguments"
```
### Task 2: Migrate LSP test callers from removed flags
**Files:**
- Modify: `lsp-server/test/run_lsp_json_tests.py`
- Modify: `lsp-server/test/test_provider/server_json_test.cppm`
- [ ] **Step 1: Run affected tests to expose obsolete arguments**
Run:
```bash
ctest --test-dir lsp-server/build/clang-linux/Release \
-R 'test_lsp_json|test_provider' --output-on-failure
```
Expected: `test_lsp_json` fails because `--log-stderr` is unknown, and the provider
server subprocess case fails because `--use-stdio` is unknown.
- [ ] **Step 2: Remove the obsolete arguments from both launch sites**
Change `lsp-server/test/run_lsp_json_tests.py` to:
```python
proc = subprocess.Popen(
[str(server_path), "--log=off"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
```
Change the command construction in
`lsp-server/test/test_provider/server_json_test.cppm` to:
```cpp
std::string command = "\"" + server_path.string() + "\" --log=off < \"" +
input_path.string() + "\" > \"" + output_path.string() + "\"";
```
- [ ] **Step 3: Format and rerun the affected tests**
Run:
```bash
clang-format -i lsp-server/test/test_provider/server_json_test.cppm
cmake --build lsp-server/build/clang-linux/Release --target test_provider
ctest --test-dir lsp-server/build/clang-linux/Release \
-R 'test_lsp_json|test_provider' --output-on-failure
```
Expected: both tests pass.
- [ ] **Step 4: Commit the LSP test-call migration**
```bash
git add lsp-server/test/run_lsp_json_tests.py \
lsp-server/test/test_provider/server_json_test.cppm
git commit -m "test(lsp): remove obsolete logger flags"
```
### Task 3: Update editor integrations and user documentation
**Files:**
- Modify: `vscode/src/extension.ts`
- Modify: `vscode/package.json`
- Modify: `vscode/README.md`
- Modify: `vim/README.md`
- [ ] **Step 1: Confirm editor integrations still reference removed flags**
Run:
```bash
rg -n --glob '!node_modules/**' -- \
'--log-stdout|--log-stderr|--use-stdio|--log=stderr' vscode vim
```
Expected: matches appear in `vscode/src/extension.ts`, `vscode/package.json`, both
editor READMEs, and nowhere else under these directories.
- [ ] **Step 2: Stop VSCode from injecting the removed flag**
Change the argument setup in `vscode/src/extension.ts` to:
```typescript
const runArgs = [...serverArguments]
const debugArgs = serverArguments.map(arg => arg.startsWith('--log=') ? '--log=trace' : arg)
if (!debugArgs.some(arg => arg.startsWith('--log='))) debugArgs.push('--log=trace')
```
Change the `tsl.server.arguments` default in `vscode/package.json` to:
```json
"default": [
"--log=info"
]
```
- [ ] **Step 3: Update documented examples without changing interpreter-path behavior**
In `vscode/README.md`, use `["--log=info"]` for both the documented default and the
configuration example.
In `vim/README.md`, use this server argument list and remove the `--log-stderr` table
row:
```json
"args": ["--log=info", "--interpreter=~/tsl64"]
```
Do not change `~/tsl64` in this task; home-directory expansion is explicitly deferred
by the design.
- [ ] **Step 4: Compile VSCode and confirm removed flags are gone from runtime callers**
Run:
```bash
npm --prefix vscode run compile
rg -n --glob '!node_modules/**' -- \
'--log-stdout|--log-stderr|--use-stdio|--log=stderr' \
vscode/src vscode/package.json vscode/README.md vim/README.md
```
Expected: TypeScript compilation succeeds and the search returns no matches.
- [ ] **Step 5: Commit editor integration updates**
```bash
git add vscode/src/extension.ts vscode/package.json vscode/README.md vim/README.md
git commit -m "refactor(editors): use fixed stderr logging"
```
### Task 4: Run the complete scoped verification gate
**Files:**
- Verify only; no planned source changes.
- [ ] **Step 1: Verify formatting and whitespace**
Run:
```bash
clang-format --dry-run --Werror \
lsp-server/src/utils/args_parser.cppm \
lsp-server/src/cli/launcher.cppm \
lsp-server/test/test_args_parser/main.cc \
lsp-server/test/test_args_parser/test_args_parser.cppm \
lsp-server/test/test_provider/server_json_test.cppm
git diff --check
```
Expected: both commands exit 0 with no diagnostics.
- [ ] **Step 2: Build all affected C++ targets**
Run:
```bash
cmake --build lsp-server/build/clang-linux/Release \
--target tsl-server test_args_parser test_provider
```
Expected: all three targets build successfully.
- [ ] **Step 3: Run all scoped CTest cases**
Run:
```bash
ctest --test-dir lsp-server/build/clang-linux/Release \
-R 'test_args_parser|test_cli_startup|test_lsp_json|test_provider' \
--output-on-failure
```
Expected: four tests run and all pass.
- [ ] **Step 4: Verify the editor build and removed runtime arguments**
Run:
```bash
npm --prefix vscode run compile
rg -n --glob '!node_modules/**' --glob '!docs/superpowers/**' -- \
'--log-stdout|--log-stderr|--use-stdio|--log=stderr' \
lsp-server/src lsp-server/test/run_lsp_json_tests.py \
lsp-server/test/test_provider vscode/src vscode/package.json \
vscode/README.md vim/README.md
```
Expected: TypeScript compilation succeeds. The search may match only negative assertions
inside `test_args_parser.cppm`; it must not match a server invocation, editor argument
list, help string, or documentation example.
- [ ] **Step 5: Inspect final changes against the design**
Run:
```bash
git status --short
git log --oneline -4
git diff HEAD~3 -- \
lsp-server/src/utils/args_parser.cppm \
lsp-server/src/cli/launcher.cppm \
lsp-server/test vscode/src/extension.ts vscode/package.json \
vscode/README.md vim/README.md
```
Expected: the diff contains only the planned parser, launcher, tests, caller migrations,
and documentation updates; unrelated pre-existing worktree changes remain untouched.
@@ -0,0 +1,374 @@
# AsyncExecutor Cooperative Cancellation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the scheduler's ambiguous ID-based cancellation and weak task state with instance-safe handles, cooperative stop tokens, stable results, and accurate active-task tracking.
**Architecture:** `TaskHandle` strongly owns a private per-submission state containing a `stop_source`, phase, final `TaskResult`, and completion synchronization. `AsyncExecutor` separately indexes the current task for each ID and every active task instance; long-running symbol and workspace operations receive the task's `stop_token` and check it at file boundaries.
**Tech Stack:** C++23 Modules, Taskflow 4, `std::stop_source` / `std::stop_token`, repository test harness, CMake/Ninja/CTest.
---
## Plan Meta
- **Plan Group:** scheduler-cancellation
- **Parent Plan:** none
- **Verification Scope:** `test_scheduler`, `test_provider`, `tsl-server`, and LSP transport smoke tests
- **Verification Gate:** all new scheduler lifecycle cases pass; provider tests and production server build without API migration errors; LSP transport smoke tests pass
- **Executor:** `executing-plans`
- **Constraints:** `karpathy-guidelines`, `.agents/`, `AGENT_RULES.md`, test-first RED/GREEN evidence, preserve unrelated worktree changes
## File map
- `lsp-server/src/scheduler/async_executor.cppm`: owns the public scheduler API and all private per-task state, cancellation, completion, metrics, and registration logic.
- `lsp-server/test/test_scheduler/test_async_executor.cppm`: specifies handle lifetime, per-instance cancellation, queued/running cancellation, duplicate-ID accounting, zero concurrency, exception, callback, and metrics behavior.
- `lsp-server/test/test_scheduler/main.cc`: calls the scheduler test module through its namespace instead of a global exported function.
- `lsp-server/src/core/server.cppm`, `lsp-server/src/core/dispatcher.cppm`, `lsp-server/src/manager/bootstrap.cppm`: migrate scheduler types and bootstrap closures/callbacks to the new namespace and result model.
- `lsp-server/src/manager/symbol.cppm`: accepts optional stop tokens and checks them at safe file-loop boundaries before publishing accumulated changes.
- `lsp-server/src/provider/initialize/initialize.cppm`, `lsp-server/src/provider/workspace/execute_command.cppm`, `lsp-server/src/provider/workspace/did_change_workspace_folders.cppm`: pass stop tokens through background workspace operations and consume `TaskResult` callbacks.
- `lsp-server/test/test_provider/*.cppm`: mechanically migrate scheduler type names and the two direct scheduler closures.
### Task 1: Specify the new scheduler lifecycle API and edge cases
**Files:**
- Modify: `lsp-server/test/test_scheduler/test_async_executor.cppm`
- Modify: `lsp-server/test/test_scheduler/main.cc`
- [ ] **Step 1: Replace the old scheduler tests with tests against the desired API**
Use `lsp::scheduler::async_executor::{AsyncExecutor, TaskStatus}` and make the module entry `lsp::test::scheduler::async_executor::Run`. Add deterministic synchronization with `std::latch`, `std::promise`, atomics, and handle waits. The suite must contain these independent cases:
```cpp
suite.Add("Retains completed task result through handle", [] {
AsyncExecutor executor{ 1 };
auto handle = executor.Submit("fast", [](std::stop_token) {
return std::optional<std::string>{ "done" };
});
auto result = handle.Wait();
Expect(handle.Valid(), "completed handle should remain valid");
Expect(result && result->status == TaskStatus::kCompleted, "task should complete");
Expect(result->value == "done", "task value should remain available");
Expect(handle.TryGetResult().has_value(), "completed result should be non-blocking");
});
suite.Add("Skips a queued task cancelled through its handle", [] {
AsyncExecutor executor{ 1 };
std::latch blocker_started{ 1 };
std::latch release_blocker{ 1 };
auto blocker = executor.Submit("blocker", [&](std::stop_token) {
blocker_started.count_down();
release_blocker.wait();
return std::optional<std::string>{};
});
blocker_started.wait();
std::atomic<bool> ran{ false };
auto queued = executor.Submit("queued", [&](std::stop_token) {
ran = true;
return std::optional<std::string>{};
});
Expect(queued.Cancel(), "first queued cancellation should succeed");
Expect(!queued.Cancel(), "repeated cancellation should fail");
release_blocker.count_down();
auto result = queued.Wait();
Expect(result && result->status == TaskStatus::kCancelled, "queued task should cancel");
Expect(!ran, "cancelled queued closure must not execute");
Expect(blocker.Wait().has_value(), "blocker should finish");
});
```
Also add cases that assert:
- `Wait` returns only after callback completion.
- an old same-ID handle cannot cancel the replacement task.
- a running closure observes `stop_requested()` and exits.
- two same-ID instances produce `GetRunningTaskCount() == 2` while active and `WaitAll()` waits for both.
- `AsyncExecutor{0}` runs a task successfully.
- task exceptions yield `kFailed` and a rethrowable original `exception_ptr`.
- callback exceptions do not deadlock or overwrite a completed task result.
- completed/cancelled/failed metrics match terminal results.
- a default-constructed handle is invalid and its methods return failure/empty values.
- [ ] **Step 2: Run the scheduler target and capture RED**
Run:
```bash
cmake --build lsp-server/build/codex43-clean/Release --target test_scheduler -j1
```
Expected: compile failure because `lsp::scheduler::async_executor`, token-taking closures, `TaskStatus`, result-returning `Wait`, and `TryGetResult` do not exist yet. This is the required RED evidence.
- [ ] **Step 3: Commit the executable specification only if it can be isolated from the breaking implementation**
Because the desired tests intentionally do not compile against the old public API, keep them in the implementation commit unless an intermediate compiling contract test is possible. Do not create a permanently broken commit.
### Task 2: Implement stable state and cooperative cancellation
**Files:**
- Modify: `lsp-server/src/scheduler/async_executor.cppm`
- [ ] **Step 1: Define the new exported result and handle API**
Export only the public namespace and types:
```cpp
export namespace lsp::scheduler::async_executor
{
enum class TaskStatus { kCompleted, kCancelled, kFailed };
struct TaskResult
{
TaskStatus status;
std::optional<std::string> value;
std::exception_ptr error;
};
struct ExecutorMetrics
{
std::size_t running = 0;
std::uint64_t submitted = 0;
std::uint64_t completed = 0;
std::uint64_t failed = 0;
std::uint64_t cancelled = 0;
};
class TaskHandle
{
public:
TaskHandle() = default;
bool Valid() const;
bool Cancel() const;
std::optional<TaskResult> Wait() const;
std::optional<TaskResult> TryGetResult() const;
private:
struct State;
explicit TaskHandle(std::shared_ptr<State> state);
std::shared_ptr<State> state_;
friend class AsyncExecutor;
};
}
```
`TaskHandle::State` must be defined only in the module implementation and contain the task ID, start time, `std::stop_source`, mutex, condition variable, pending/running/completed phase, optional final result, and callback-completed flag.
- [ ] **Step 2: Replace the executor API and registration model**
Use these public aliases and methods:
```cpp
using TaskClosure = std::function<std::optional<std::string>(std::stop_token)>;
using TaskCallback = std::function<void(const TaskResult&)>;
explicit AsyncExecutor(std::size_t concurrency = std::thread::hardware_concurrency());
TaskHandle Submit(const std::string& task_id, TaskClosure task,
TaskCallback callback = nullptr);
bool Cancel(const std::string& task_id);
void WaitAll();
std::size_t GetRunningTaskCount() const;
void LogStatus() const;
ExecutorMetrics GetStatistics() const;
```
Remove `WaitForTask`, the exported `detail` namespace, the raw executor pointer from `TaskHandle`, `ActiveEntry`, and `kStatusLogInterval`. Store current-ID states in `current_tasks_` and every unfinished instance in `active_tasks_`. Document beside `TaskCallback` that a callback must not call `Wait()` on its own handle or call `WaitAll()` on the same executor, because either operation would self-wait.
- [ ] **Step 3: Implement the cancellation synchronization point**
`TaskHandle::Cancel()` calls `request_stop()` on its own state only while its phase is not completed. `AsyncExecutor::Cancel(id)` snapshots only `current_tasks_[id]` and performs the same operation. `ExecuteTask` locks the state before starting: if stop is already requested, it completes as cancelled without invoking the closure; otherwise it changes pending to running and calls the closure with `state->stop_source.get_token()`.
- [ ] **Step 4: Implement terminal results and callback completion**
Build exactly one `TaskResult` per task:
```cpp
TaskResult result;
try {
auto value = task(token);
result = token.stop_requested()
? TaskResult{ TaskStatus::kCancelled, std::nullopt, nullptr }
: TaskResult{ TaskStatus::kCompleted, std::move(value), nullptr };
} catch (...) {
result = TaskResult{ TaskStatus::kFailed, std::nullopt,
std::current_exception() };
}
```
Write the result before invoking the callback. Catch and log callback exceptions without changing `result`. Remove the exact state instance from both registries, mark callback completion, update exactly one terminal metric, and then notify waiters. This ordering makes `Wait()` imply that callback execution and registry cleanup are both complete.
- [ ] **Step 5: Normalize concurrency and wait for all Taskflow work**
Construct Taskflow with `std::max<std::size_t>(1, concurrency)`. `WaitAll()` must use Taskflow's `wait_for_all()` so callback-submitted work is drained; completed states must already be removed from `active_tasks_`. The destructor continues to call `WaitAll()` and `LogStatus()`.
- [ ] **Step 6: Build and run the scheduler tests for GREEN**
Run:
```bash
cmake --build lsp-server/build/codex43-clean/Release --target test_scheduler -j1
ctest --test-dir lsp-server/build/codex43-clean/Release -R '^test_scheduler$' --output-on-failure
```
Expected: target builds and the complete scheduler suite passes.
### Task 3: Migrate namespaces and scheduler call signatures
**Files:**
- Modify: `lsp-server/src/core/server.cppm`
- Modify: `lsp-server/src/core/dispatcher.cppm`
- Modify: `lsp-server/src/manager/bootstrap.cppm`
- Modify: `lsp-server/src/provider/initialize/initialize.cppm`
- Modify: `lsp-server/src/provider/workspace/execute_command.cppm`
- Modify: `lsp-server/src/provider/workspace/did_change_workspace_folders.cppm`
- Modify: `lsp-server/test/test_provider/completion_test.cppm`
- Modify: `lsp-server/test/test_provider/definitions_test.cppm`
- Modify: `lsp-server/test/test_provider/interpreter_test.cppm`
- Modify: `lsp-server/test/test_provider/json_flow_test.cppm`
- Modify: `lsp-server/test/test_provider/json_provider_coverage_test.cppm`
- Modify: `lsp-server/test/test_provider/provider_misc_test.cppm`
- Modify: `lsp-server/test/test_provider/provider_surface_test.cppm`
- [ ] **Step 1: Mechanically migrate scheduler type names**
Replace every `scheduler::AsyncExecutor` with `scheduler::async_executor::AsyncExecutor` and every fully qualified `lsp::scheduler::AsyncExecutor` with `lsp::scheduler::async_executor::AsyncExecutor`. Do not alter unrelated namespace migrations already present in the worktree.
- [ ] **Step 2: Migrate every submitted closure and callback**
Every closure accepts a `std::stop_token`, using `[[maybe_unused]]` only for genuinely short tasks. Every callback accepts `const scheduler::async_executor::TaskResult&` (or a local alias) and switches on `result.status`; successful values are read from `result.value`. For example:
```cpp
async_executor.Submit(
task_name,
[&hub, path](std::stop_token stop_token) -> std::optional<std::string> {
hub.symbols().LoadSystemLibrary(path, stop_token);
return std::format("Loaded system library: {}", path);
},
[path](const scheduler::async_executor::TaskResult& result) {
if (result.status == scheduler::async_executor::TaskStatus::kCancelled)
spdlog::info("System library load task cancelled: {}", path);
else if (result.status == scheduler::async_executor::TaskStatus::kCompleted && result.value)
spdlog::info("{}", *result.value);
});
```
- [ ] **Step 3: Prove the migration is complete statically**
Run:
```bash
rg -n 'scheduler::AsyncExecutor|lsp::scheduler::AsyncExecutor|\[.*\]\(\) -> std::optional<std::string>|const std::optional<std::string>&.*, bool cancelled|WaitForTask|GetResult\(' lsp-server/src lsp-server/test
```
Expected: no scheduler-related legacy matches. Any unrelated optional-returning lambda must be inspected rather than blindly changed.
### Task 4: Propagate stop tokens through symbol and workspace loops
**Files:**
- Modify: `lsp-server/src/manager/symbol.cppm`
- Modify: `lsp-server/src/manager/bootstrap.cppm`
- Modify: `lsp-server/src/provider/initialize/initialize.cppm`
- Modify: `lsp-server/src/provider/workspace/execute_command.cppm`
- Modify: `lsp-server/src/provider/workspace/did_change_workspace_folders.cppm`
- [ ] **Step 1: Add optional stop tokens to long symbol operations**
Use these signatures so synchronous callers need no artificial token while scheduler callers pass one explicitly:
```cpp
void LoadSystemLibrary(const std::string& lib_path,
std::stop_token stop_token = {});
void LoadWorkspace(const protocol::DocumentUri& workspace_uri,
std::stop_token stop_token = {});
void IndexWorkspaceFiles(const std::vector<protocol::DocumentUri>& uris,
std::stop_token stop_token = {});
void RemoveWorkspaceFiles(const std::vector<protocol::DocumentUri>& uris,
std::stop_token stop_token = {});
```
At the start of every file/directory loop iteration, return when `stop_token.stop_requested()`. Check once more before acquiring `mutex_` and publishing accumulated map changes, so cancelled work does not publish a partial replacement assembled before cancellation.
- [ ] **Step 2: Make workspace enumeration cooperative**
Change the helper to:
```cpp
std::vector<protocol::DocumentUri> EnumerateWorkspaceFiles(
const protocol::DocumentUri& workspace_uri,
std::stop_token stop_token)
```
Check the token before filesystem setup and at the start of each recursive iterator iteration. The submitted closure must check again before calling `IndexWorkspaceFiles` or `RemoveWorkspaceFiles`, then pass the same token into those methods.
- [ ] **Step 3: Build production and provider targets**
Run:
```bash
cmake --build lsp-server/build/codex43-clean/Release --target test_provider tsl-server -j1
```
Expected: both targets build without stale scheduler namespace, closure, callback, or symbol signatures.
### Task 5: Format and verify the complete behavior
**Files:**
- Modify only files listed in Tasks 1-4 if formatting changes are needed.
- Modify: `memory-bank/progress.md`
- [ ] **Step 1: Format touched C++ files**
Run `clang-format -i` only on the C++ files changed by this plan. Do not format unrelated dirty files.
- [ ] **Step 2: Rebuild and run focused tests**
Run:
```bash
cmake --build lsp-server/build/codex43-clean/Release --target test_scheduler test_provider tsl-server -j1
ctest --test-dir lsp-server/build/codex43-clean/Release -R '^test_scheduler$|^test_provider$' --output-on-failure
```
Expected: build succeeds and both tests pass.
- [ ] **Step 3: Run LSP transport smoke tests without fixture validation**
Run the repository's existing LSP test entry with `--no-validate` against `lsp-server/build/codex43-clean/Release/src/tsl-server`, using the same invocation recorded by the preceding UTF-16 plan or discover it with `rg -- '--no-validate' lsp-server/test`.
Expected: transport startup, initialize, shutdown, and exit complete successfully.
- [ ] **Step 4: Inspect final diffs and legacy API absence**
Run:
```bash
git diff --check
rg -n 'WaitForTask|namespace lsp::scheduler\s*$|scheduler::AsyncExecutor|lsp::scheduler::AsyncExecutor' lsp-server/src/scheduler lsp-server/src/core lsp-server/src/manager lsp-server/src/provider lsp-server/test
git status --short
```
Expected: no whitespace errors or legacy scheduler API references; unrelated pre-existing dirty files remain unstaged.
- [ ] **Step 5: Commit the implementation with the required emoji convention**
Stage only the files from Tasks 1-4 and commit:
```bash
git commit -m ":bug: fix(async_executor): implement cooperative cancellation"
```
- [ ] **Step 6: Finish the main-loop record and update the human summary**
Run:
```bash
python docs/standards/playbook/scripts/main_loop.py finish \
-plan docs/superpowers/plans/2026-07-12-async-executor-cancellation.md \
-status done \
-progress memory-bank/progress.md
```
Then update `memory-bank/progress.md` Current Focus, Recent Changes, Next Steps, and Open Risks with the verified scheduler outcome, stage only that file, and commit:
```bash
git commit -m ":memo: docs(progress): finish async executor cancellation plan"
```
@@ -0,0 +1,409 @@
# LSP UTF-16 Text Coordinates Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Convert LSP UTF-16 positions into consistent UTF-8 byte offsets and Tree-sitter byte points.
**Architecture:** `lsp.utils.text_coordinates` exposes one `ToBytePosition` operation that resolves both representations in a single scan, while `ToOffset` remains a wrapper for provider callers. Incremental parsing consumes the paired result so `TSInputEdit` byte offsets and points always refer to the same source location.
**Tech Stack:** C++23 Modules, LSP 3.17 UTF-16 positions, UTF-8, Tree-sitter, CMake, CTest
---
## Plan Meta
- **Plan Group:** text-coordinates-utf16
- **Parent Plan:** none
- **Verification Scope:** text-coordinate regression tests, provider test suite, server build, LSP transport smoke test
- **Verification Gate:** `test_provider` passes, `tsl-server` builds, changed C++ files pass clang-format, and the LSP JSON transport completes with response validation disabled
- **Execution Constraints:** `karpathy-guidelines`, `.agents`, `AGENT_RULES.md`, test-driven development
## File Map
- Create `lsp-server/test/test_provider/text_coordinates_test.cppm`: focused UTF-16-to-byte coordinate regression tests.
- Modify `lsp-server/test/test_provider/CMakeLists.txt`: compile the new test module in `test_provider`.
- Modify `lsp-server/test/test_provider/test_main.cppm`: register the text-coordinate tests.
- Modify `lsp-server/src/utils/text_coordinates.cppm`: add paired byte-position resolution and correct UTF-16 decoding.
- Modify `lsp-server/src/manager/parser.cppm`: build `TSInputEdit` from paired coordinate results.
### Task 1: Drive UTF-16 coordinate conversion with regression tests
**Files:**
- Create: `lsp-server/test/test_provider/text_coordinates_test.cppm`
- Modify: `lsp-server/test/test_provider/CMakeLists.txt`
- Modify: `lsp-server/test/test_provider/test_main.cppm`
- [ ] **Step 1: Add the focused test module**
Create `lsp-server/test/test_provider/text_coordinates_test.cppm`:
```cpp
module;
export module lsp.test.provider.text_coordinates;
import std;
import lsp.protocol;
import lsp.test.framework;
import lsp.utils.text_coordinates;
export namespace lsp::test::provider
{
class TextCoordinatesTests
{
public:
static void Register(TestRunner& runner);
private:
static TestResult TestAsciiAndBmpPositions();
static TestResult TestSupplementaryPlanePositions();
static TestResult TestMultilineAndClampedPositions();
static TestResult TestMalformedUtf8IsBounded();
static TestResult TestCalculateEndPointUsesByteColumns();
};
}
namespace lsp::test::provider
{
namespace
{
void ExpectPosition(const utils::text_coordinates::BytePosition& actual,
protocol::uinteger offset,
std::uint32_t row,
std::uint32_t column)
{
assertEqual(offset, actual.offset, "byte offset should match");
assertEqual(row, actual.point.row, "Tree-sitter row should match");
assertEqual(column, actual.point.column, "Tree-sitter byte column should match");
}
}
void TextCoordinatesTests::Register(TestRunner& runner)
{
runner.addTest("text coordinates convert ASCII and BMP positions", TestAsciiAndBmpPositions);
runner.addTest("text coordinates convert supplementary-plane positions", TestSupplementaryPlanePositions);
runner.addTest("text coordinates clamp multiline positions", TestMultilineAndClampedPositions);
runner.addTest("text coordinates bound malformed UTF-8", TestMalformedUtf8IsBounded);
runner.addTest("text coordinates calculate byte end points", TestCalculateEndPointUsesByteColumns);
}
TestResult TextCoordinatesTests::TestAsciiAndBmpPositions()
{
const protocol::string content = "A中Z";
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 0 }, content), 0U, 0U, 0U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 1 }, content), 1U, 0U, 1U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 2 }, content), 4U, 0U, 4U);
assertEqual(4U, utils::text_coordinates::ToOffset({ 0, 2 }, content),
"ToOffset should use the paired conversion");
return { "", true, "ok" };
}
TestResult TextCoordinatesTests::TestSupplementaryPlanePositions()
{
const protocol::string content = "A😀Z";
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 1 }, content), 1U, 0U, 1U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 2 }, content), 1U, 0U, 1U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 3 }, content), 5U, 0U, 5U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 4 }, content), 6U, 0U, 6U);
return { "", true, "ok" };
}
TestResult TextCoordinatesTests::TestMultilineAndClampedPositions()
{
const protocol::string content = "中x\n😀y";
ExpectPosition(utils::text_coordinates::ToBytePosition({ 1, 0 }, content), 5U, 1U, 0U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 1, 2 }, content), 9U, 1U, 4U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 1, 100 }, content), 10U, 1U, 5U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 9, 0 }, content), 10U, 1U, 5U);
return { "", true, "ok" };
}
TestResult TextCoordinatesTests::TestMalformedUtf8IsBounded()
{
const protocol::string content{ 'A', static_cast<char>(0xF0), static_cast<char>(0x9F), 'Z' };
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 2 }, content), 2U, 0U, 2U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 100 }, content), 4U, 0U, 4U);
return { "", true, "ok" };
}
TestResult TextCoordinatesTests::TestCalculateEndPointUsesByteColumns()
{
const auto end = utils::text_coordinates::CalculateEndPoint("\n😀x", { 2U, 3U });
assertEqual(3U, end.row, "newline should advance the Tree-sitter row");
assertEqual(5U, end.column, "multibyte text should advance the byte column");
return { "", true, "ok" };
}
}
```
- [ ] **Step 2: Register the new test module in the provider target**
Add `text_coordinates_test.cppm` beside the other test modules in both `SOURCES` and the CMake `FILE_SET` in `lsp-server/test/test_provider/CMakeLists.txt`:
```cmake
text_coordinates_test.cppm
```
Import and register it in `lsp-server/test/test_provider/test_main.cppm`:
```cpp
import lsp.test.provider.text_coordinates;
```
```cpp
std::cout << " - Text coordinate tests" << std::endl;
lsp::test::provider::TextCoordinatesTests::Register(runner);
```
- [ ] **Step 3: Build to verify the test is red**
Run:
```bash
cmake --build lsp-server/build/codex43-clean/Release --target test_provider -j2
```
Expected: build fails because `BytePosition` and `ToBytePosition` do not exist. This proves the test requires the new paired conversion API.
### Task 2: Implement one UTF-16-to-byte conversion path
**Files:**
- Modify: `lsp-server/src/utils/text_coordinates.cppm`
- Modify: `lsp-server/src/manager/parser.cppm`
- [ ] **Step 1: Replace the independent point conversion with paired resolution**
In the exported namespace of `lsp-server/src/utils/text_coordinates.cppm`, replace `ToPoint` with:
```cpp
struct BytePosition
{
protocol::uinteger offset;
TSPoint point;
};
BytePosition ToBytePosition(const protocol::Position& position, const protocol::string& content);
```
In the implementation namespace, add a validated UTF-8 decoder and implement the paired conversion:
```cpp
namespace
{
struct DecodedCharacter
{
std::size_t byte_count;
protocol::uinteger utf16_units;
};
DecodedCharacter DecodeCharacter(std::string_view content, std::size_t offset)
{
const auto lead = static_cast<unsigned char>(content[offset]);
if ((lead & 0x80U) == 0)
return { 1, 1 };
std::size_t byte_count = 0;
std::uint32_t code_point = 0;
std::uint32_t minimum = 0;
if ((lead & 0xE0U) == 0xC0U)
{
byte_count = 2;
code_point = lead & 0x1FU;
minimum = 0x80U;
}
else if ((lead & 0xF0U) == 0xE0U)
{
byte_count = 3;
code_point = lead & 0x0FU;
minimum = 0x800U;
}
else if ((lead & 0xF8U) == 0xF0U)
{
byte_count = 4;
code_point = lead & 0x07U;
minimum = 0x10000U;
}
else
{
return { 1, 1 };
}
if (byte_count > content.size() - offset)
return { 1, 1 };
for (std::size_t index = 1; index < byte_count; ++index)
{
const auto continuation = static_cast<unsigned char>(content[offset + index]);
if ((continuation & 0xC0U) != 0x80U)
return { 1, 1 };
code_point = (code_point << 6U) | (continuation & 0x3FU);
}
if (code_point < minimum || code_point > 0x10FFFFU ||
(code_point >= 0xD800U && code_point <= 0xDFFFU))
{
return { 1, 1 };
}
return { byte_count, code_point >= 0x10000U ? 2U : 1U };
}
}
BytePosition ToBytePosition(const protocol::Position& position, const protocol::string& content)
{
std::size_t offset = 0;
std::size_t line_start = 0;
std::uint32_t row = 0;
while (offset < content.size() && row < position.line)
{
if (content[offset++] == '\n')
{
++row;
line_start = offset;
}
}
protocol::uinteger utf16_units = 0;
while (offset < content.size() && content[offset] != '\n' &&
utf16_units < position.character)
{
const auto decoded = DecodeCharacter(content, offset);
const auto remaining = position.character - utf16_units;
if (decoded.utf16_units > remaining)
break;
offset += decoded.byte_count;
utf16_units += decoded.utf16_units;
}
return {
.offset = static_cast<protocol::uinteger>(offset),
.point = TSPoint{
.row = row,
.column = static_cast<std::uint32_t>(offset - line_start),
},
};
}
protocol::uinteger ToOffset(const protocol::Position& position, const protocol::string& content)
{
return ToBytePosition(position, content).offset;
}
```
Keep `CalculateEndPoint` unchanged: Tree-sitter columns count UTF-8 bytes, so its byte iteration is correct.
- [ ] **Step 2: Use paired positions for incremental Tree-sitter edits**
Replace the separate offset and point conversions in `SyntaxTree::ApplyEdit` in `lsp-server/src/manager/parser.cppm`:
```cpp
const auto start = utils::text_coordinates::ToBytePosition(change.range.start, content);
const auto old_end = utils::text_coordinates::ToBytePosition(change.range.end, content);
TSInputEdit edit{};
edit.start_byte = start.offset;
edit.old_end_byte = old_end.offset;
edit.new_end_byte = start.offset + change.text.length();
edit.start_point = start.point;
edit.old_end_point = old_end.point;
edit.new_end_point = utils::text_coordinates::CalculateEndPoint(change.text, edit.start_point);
```
- [ ] **Step 3: Build and run the regression suite to verify green**
Run:
```bash
cmake --build lsp-server/build/codex43-clean/Release --target test_provider -j2
ctest --test-dir lsp-server/build/codex43-clean/Release -R '^test_provider$' --output-on-failure
```
Expected: the build succeeds and CTest reports `100% tests passed, 0 tests failed out of 1`.
- [ ] **Step 4: Format the changed C++ files and verify formatting**
Run:
```bash
clang-format -i \
lsp-server/src/utils/text_coordinates.cppm \
lsp-server/src/manager/parser.cppm \
lsp-server/test/test_provider/text_coordinates_test.cppm \
lsp-server/test/test_provider/test_main.cppm
clang-format --dry-run --Werror \
lsp-server/src/utils/text_coordinates.cppm \
lsp-server/src/manager/parser.cppm \
lsp-server/test/test_provider/text_coordinates_test.cppm \
lsp-server/test/test_provider/test_main.cppm
```
Expected: both commands exit successfully with no diagnostics.
- [ ] **Step 5: Commit the implementation**
```bash
git add \
lsp-server/src/utils/text_coordinates.cppm \
lsp-server/src/manager/parser.cppm \
lsp-server/test/test_provider/CMakeLists.txt \
lsp-server/test/test_provider/test_main.cppm \
lsp-server/test/test_provider/text_coordinates_test.cppm
git commit -m "fix: convert LSP UTF-16 text coordinates"
```
### Task 3: Verify server integration
**Files:**
- Verify only; no additional source changes expected.
- [ ] **Step 1: Build the production server and provider tests**
Run:
```bash
cmake --build lsp-server/build/codex43-clean/Release --target tsl-server test_provider -j2
```
Expected: Ninja completes successfully.
- [ ] **Step 2: Run provider and LSP transport verification**
Run:
```bash
ctest --test-dir lsp-server/build/codex43-clean/Release -R '^test_provider$' --output-on-failure
python lsp-server/test/run_lsp_json_tests.py \
--server lsp-server/build/codex43-clean/Release/tsl-server \
--no-validate
```
Expected: `test_provider` reports zero failures and the LSP script completes all request/response exchanges successfully. Response validation remains disabled because the repository has a known unrelated rename-response fixture failure.
- [ ] **Step 3: Confirm the old point-only API has no callers**
Run:
```bash
rg -n 'text_coordinates::ToPoint|text::ToPoint|TSPoint ToPoint' lsp-server/src lsp-server/test
```
Expected: no matches.
- [ ] **Step 4: Record completion through the repository main loop**
After all verification gates pass, run:
```bash
python docs/standards/playbook/scripts/main_loop.py finish \
-plan docs/superpowers/plans/2026-07-12-text-coordinates-utf16.md \
-status done \
-progress memory-bank/progress.md
```
Expected: the plan is recorded as `done`. Update the human-readable summary in `memory-bank/progress.md` to mention the UTF-16 coordinate fix and its passing verification, then commit that state update separately.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
# 参数解析与启动错误处理设计
## 背景
`lsp.utils.args_parser` 当前同时承担参数解析、全局配置存储、帮助输出和
日志初始化。无效线程数与日志文件打开失败可能触发未捕获异常,帮助文本输出
字面量 `\n`,而将日志写入 stdout 会破坏 LSP stdio 协议流。
本设计处理审查项 1、2、3、5。解释器路径中的 `~` 展开留到后续任务。
## 目标
- stdout 只承载 LSP 协议消息。
- 参数解析无全局可变状态,可直接单元测试。
- 所有参数都进行完整校验,错误参数返回明确诊断和退出码 2。
- 日志初始化或服务器运行失败返回退出码 1,不触发 abort。
- 帮助文本使用真实换行并准确反映受支持参数。
## 非目标
- 不处理解释器路径展开或路径存在性验证。
- 不保留旧参数兼容层。
- 不引入第三方命令行解析库。
## 参数解析接口
删除 `ArgsParser` 单例、`GetConfig()` 和内部 `config_`。模块改为导出无状态
解析接口,结果通过 C++23 `std::expected` 返回:
```cpp
enum class ParseAction
{
kRun,
kShowHelp,
};
struct ParseResult
{
ParseAction action = ParseAction::kRun;
ServerConfig config;
};
std::expected<ParseResult, std::string> ParseArgs(
int argc,
char* const argv[]);
void PrintHelp(std::ostream& output, std::string_view program_name);
```
`ServerConfig` 只保留运行配置:线程数、日志级别、日志文件和解释器路径。
帮助请求由 `ParseAction` 表达,不再混入服务器配置。
## 参数规则
支持以下参数:
- `--help`
- `--log=trace|debug|info|warn|error|off`
- `--log-file=<path>`
- `--threads=<count>`
- `--interpreter=<path>`
删除 `--log-stdout``--log-stderr``--use-stdio`。没有指定日志文件时,
日志始终写入 stderr。
解析遵循以下规则:
- 未知参数返回错误,不再静默忽略。
- `--threads` 使用 `std::from_chars` 完整解析,只接受 `1``256`
- `--threads` 的空值、负数、尾随字符和数值溢出均返回错误。
- `--log` 只接受列出的六个级别。
- `--log-file``--interpreter` 不接受空值。
- `--help` 在参数列表中具有最高优先级;只要出现便返回帮助动作,不校验
其他参数,也不初始化日志或服务器。
- 普通参数重复出现时,以最后一次出现的值为准。
## 启动与错误处理
日志初始化从参数解析模块移到 `lsp.cli.launcher` 的私有实现。初始化规则为:
- `log_file` 非空时创建文件 logger。
- 否则创建 stderr logger。
- 不提供 stdout logger 路径。
`Run()` 按阶段处理错误:
1. 调用 `ParseArgs()`;错误写入 stderr,并返回 2。
2. `kShowHelp` 将帮助写入 stdout,并返回 0。
3. 初始化日志;异常写入 stderr,并返回 1。
4. 构造并运行 `LspServer`;异常写入 stderr,记录到已初始化的 logger
并返回 1。
5. 正常停止后关闭 spdlog,并返回 0。
日志初始化失败时不调用 spdlog 记录该错误,避免在 logger 未就绪时产生二次
异常。
## 测试设计
新增参数解析单元测试,覆盖:
- 默认配置与所有有效日志级别。
- `--threads=1``--threads=256` 两个边界。
- 空值、零、负数、257、尾随字符和溢出线程数。
- 空日志文件、空解释器路径和未知参数。
- 已删除的三个日志输出参数被报告为未知参数。
- 帮助动作和帮助文本的真实换行。
新增或扩展 CLI 行为测试,覆盖:
- 无日志文件时日志只进入 stderr,stdout 不出现日志文本。
- 无法创建日志文件时进程返回 1,并输出可读错误,而非因信号终止。
- 参数错误返回 2,且不会初始化服务器。
验证命令至少包括目标构建、参数解析测试和 CLI 行为测试。现有 LSP JSON
测试也需要通过,以确认 stdout 协议流未受影响。
## 完成条件
- 旧单例和 stdout logger 路径已删除。
- 所有参数规则均有自动化测试。
- `--help` 输出为多行文本。
- 无效线程数、不可写日志文件均受控退出。
- LSP stdio 测试通过,stdout 中不混入日志。
@@ -0,0 +1,202 @@
# AsyncExecutor 协作式取消设计
## 背景
`lsp.scheduler.async_executor` 当前通过 Taskflow 执行后台任务,但任务句柄只
弱引用执行状态,快速任务完成后句柄立即失效;句柄取消按任务 ID 回查,可能
误取消同 ID 的替代任务;排队任务即使已取消仍会执行。任务注册表同时承担
“当前 ID”与“全部活跃任务”两种职责,导致等待、统计和重复 ID 语义不稳定。
本设计不保留现有 API 兼容层,直接建立明确的任务生命周期、结果和协作式
取消契约。
## 目标
- 任务句柄在任务完成后仍可等待并读取最终结果。
- 句柄取消只影响该句柄对应的任务实例。
- 按 ID 取消只影响该 ID 当前注册的任务实例。
- 已取消的排队任务不执行任务体。
- 运行中的长任务通过 `std::stop_token` 协作退出。
- 重复 ID 的全部活跃任务被准确等待和计数。
- 任务成功、取消和失败使用单一结果模型表达。
- 隐藏执行状态、同步原语和句柄构造细节。
- API namespace 与 Module 名保持一致。
## 非目标
- 不强制终止正在运行的线程。
- 不为旧的 `TaskClosure`、callback 或 `WaitForTask` 提供兼容重载。
- 不在本任务中启用 LSP `$/cancelRequest` 的请求级异步分发;本设计只保证
scheduler 及其后台索引任务具备正确取消能力。
- 不修改 Taskflow 本身。
## 公共 API
Module 名保持:
```cpp
export module lsp.scheduler.async_executor;
```
公共 API 迁移到:
```cpp
namespace lsp::scheduler::async_executor
```
任务结果统一为:
```cpp
enum class TaskStatus
{
kCompleted,
kCancelled,
kFailed,
};
struct TaskResult
{
TaskStatus status;
std::optional<std::string> value;
std::exception_ptr error;
};
```
任务 closure 只产生业务值,最终状态由 executor 根据停止请求和异常统一
生成,避免任务体自行构造相互矛盾的状态:
```cpp
using TaskClosure =
std::function<std::optional<std::string>(std::stop_token)>;
using TaskCallback = std::function<void(const TaskResult&)>;
```
`TaskHandle` 对外提供:
```cpp
bool Valid() const;
bool Cancel() const;
std::optional<TaskResult> Wait() const;
std::optional<TaskResult> TryGetResult() const;
```
- 默认构造句柄无效,`Wait``TryGetResult` 返回 `std::nullopt`
- 有效句柄完成后仍保持有效。
- `Cancel` 仅在首次成功请求取消且任务尚未完成时返回 `true`
- `Wait` 等待任务体和 callback 都结束,并返回最终任务结果。
- `TryGetResult` 不阻塞;尚未完成时返回 `std::nullopt`
- 构造函数为私有,仅 `AsyncExecutor` 可以创建有效句柄。
删除 `AsyncExecutor::WaitForTask`。调用方必须保存 `TaskHandle` 来等待特定
任务;按字符串 ID 只用于取消当前实例,不承担历史结果查询。
## 内部状态与所有权
`TaskHandle` 通过 `std::shared_ptr` 强持有私有嵌套状态。状态至少包含:
- `std::stop_source`:生成 stop token 并接受取消请求。
- `TaskPhase``kPending``kRunning``kCompleted`
- `std::optional<TaskResult>`:最终结果。
- mutex、condition variable 和 callback 完成标记。
- task ID 与开始时间。
状态定义不导出,外部不能构造或修改同步字段。
`AsyncExecutor` 使用两份索引:
- `current_tasks_``task_id -> state`,只表示该 ID 当前实例,供
`Cancel(id)` 使用。
- `active_tasks_`:以任务实例唯一标识保存全部未完成状态,供
`WaitAll``GetRunningTaskCount` 与统计使用。
提交同 ID 新任务时,请求旧实例停止,然后替换 `current_tasks_`;旧实例仍
留在 `active_tasks_`,直到任务体和 callback 都完成。
## 取消与执行流程
### 排队取消
worker 获取任务状态后,在持锁状态下检查 stop token:
- 已请求停止:不调用 closure,直接产生 `kCancelled`
- 未请求停止:状态从 `kPending` 转为 `kRunning`,随后调用 closure。
因此取消和开始执行之间只有一个明确的同步竞态点;取消先获得状态锁时,
任务体保证不会执行。
### 运行中取消
任务开始后,`Cancel` 调用 `stop_source.request_stop()`。executor 不强制停止
线程;closure 接收 `std::stop_token`,在文件枚举、索引和符号加载循环的
安全边界调用 `stop_requested()` 并尽快返回。
如果运行中收到停止请求,即使 closure 已产生普通值,executor 仍将最终
状态归类为 `kCancelled`,避免向 callback 报告成功。
### 完成与 callback
executor 捕获任务异常并生成 `kFailed`,保留原始 `exception_ptr`。任务结果
先写入状态,再调用 callback;callback 异常被捕获并记录,但不改写已经确定
的任务结果。只有 callback 返回或异常被处理后,状态才标记为完全完成、从
两个注册表注销并唤醒等待者。
callback 不得从自身调用 `Wait``WaitAll`;该约束写入接口注释和测试命名,
避免自等待死锁。
## 并发数与生命周期
传入并发数 0 或 `std::thread::hardware_concurrency()` 返回 0 时,实际 worker
数收敛到 1,再构造 `tf::Executor`
`AsyncExecutor` 析构时调用 `WaitAll``WaitAll` 等待所有活跃状态完成,并再
调用 Taskflow `wait_for_all()`,确保 callback 中提交的后续任务也已退出。
析构期间不允许其他线程继续调用 `Submit`;这是对象生命周期的基本前置条件。
## 调用方迁移
所有 `scheduler::AsyncExecutor``scheduler::TaskHandle` 等引用迁移到
`scheduler::async_executor::*`。所有 `Submit` closure 增加
`std::stop_token` 参数,callback 改为接收 `const TaskResult&`
工作区加载、系统库加载、文件索引和 workspace folder 变更任务将 stop token
传入长循环;循环在每个文件或目录边界检查停止请求。只执行一次且不可拆分的
第三方调用在调用前后检查 token,不尝试中断其内部线程。
## 清理
- 删除公开的 `detail::ExecutionState``detail::ActiveEntry`
-`ExecutorMetrics` 作为明确公共结果类型保留在
`lsp::scheduler::async_executor`
- 删除未使用的 `ActiveEntry::callback``ActiveEntry::start_time`
`kStatusLogInterval`
- 测试 Module 的入口同步放入自身命名空间,不再导出全局 `Run`
## 测试设计
测试遵循 TDD,先在旧实现上观察失败,再实现新语义:
- 无延迟任务完成后,handle 仍有效且可读取结果。
- `Wait` 返回任务体与 callback 完成后的最终结果。
- 同 ID 替换后,旧 handle 取消不影响新任务。
- 排队任务被取消后,任务体从未执行。
- 运行中任务观察 stop token 并协作退出。
- 同 ID 两个活跃实例被计数为 2`WaitAll` 等待两者。
- 并发数 0 可以正常执行任务。
- task 异常生成 `kFailed` 并保留异常。
- callback 异常不会导致等待死锁,也不会改写任务结果。
- 成功、取消、失败统计与最终状态一致。
迁移完成后运行 scheduler 测试、provider 测试和生产服务器构建;LSP 传输
冒烟测试确认后台初始化和 workspace 操作没有回归。
## 完成条件
- 快速完成任务的 handle 不失效。
- 任一 handle 只能取消自己的状态。
- 已取消的排队 closure 不执行。
- 长任务可以通过 stop token 提前退出。
- `GetRunningTaskCount``WaitAll` 覆盖重复 ID 的所有实例。
- 不存在 `WaitForTask`、公开执行状态或未使用调度字段。
- 并发数始终至少为 1。
- namespace、调用方和测试全部迁移完成。
- 约定范围内构建与测试通过。
@@ -0,0 +1,103 @@
# LSP UTF-16 文本坐标转换设计
## 背景
LSP `Position.character` 在服务端未协商其他编码时使用 UTF-16 code unit。
当前 `lsp.utils.text_coordinates` 按 Unicode 码点递增字符位置,并把 LSP 列号
直接作为 Tree-sitter 字节列。这会使 emoji 后的字节偏移发生偏移,也会使中文
或 emoji 前后的增量编辑向 Tree-sitter 提交错误点位。
当前 VSCode 扩展使用的 `vscode-languageclient` 9.0.1 只声明 `utf-16`,并拒绝
服务端选择其他位置编码,因此本设计完整支持 UTF-16,不增加编码协商。
## 目标
- 将 LSP UTF-16 位置准确转换为 UTF-8 源码字节偏移。
- 向 Tree-sitter 提供基于 UTF-8 字节的行列坐标。
- 字节偏移和 Tree-sitter 点位来自同一次坐标解析,避免两套转换产生差异。
- 为 ASCII、中文、emoji、跨行和越界位置增加自动化回归测试。
## 非目标
- 不实现 UTF-8、UTF-16、UTF-32 的运行时协商。
- 不修改 LSP 初始化能力。
- 不处理 `string.cppm` 的空白裁剪或未使用接口。
- 不改变源码的 UTF-8 存储方式。
## 坐标接口
导出一个同时包含字节偏移和 Tree-sitter 点位的解析结果:
```cpp
struct BytePosition
{
protocol::uinteger offset;
TSPoint point;
};
BytePosition ToBytePosition(
const protocol::Position& position,
const protocol::string& content);
protocol::uinteger ToOffset(
const protocol::Position& position,
const protocol::string& content);
```
删除无法独立正确转换的 `ToPoint(const Position&)`。普通 provider 继续使用
`ToOffset`;增量解析调用 `ToBytePosition`,从同一结果取得 `offset`
`point``CalculateEndPoint` 保持现有职责,按插入文本的 UTF-8 字节更新
Tree-sitter 点位。
## 转换规则
转换从文档开头扫描到目标行,再扫描目标行中的 UTF-8 序列:
- ASCII 和 BMP 码点消耗一个 UTF-16 code unit。
- U+10000 及以上码点消耗两个 UTF-16 code unit。
- Tree-sitter `column` 等于目标位置相对当前行首的 UTF-8 字节数。
- 超过行尾或文档末尾的位置收敛到可表示的行尾或文档末尾。
- 指向 UTF-16 surrogate pair 中间的位置收敛到该 Unicode 码点起点,绝不
返回 UTF-8 序列内部的字节偏移。
- 不完整或无效的 UTF-8 起始字节按单个字节处理,转换过程不得越过行尾或
文档末尾。
返回的 `offset``point` 必须始终指向同一个源码位置。有效 LSP 位置保持
精确;无效或越界位置采用稳定的收敛行为,避免产生越界访问。
## 增量解析
`SyntaxTree::ApplyEdit` 对变更起点和终点分别调用 `ToBytePosition`
- `start_byte` / `old_end_byte` 使用结果中的字节偏移。
- `start_point` / `old_end_point` 使用同一结果中的 Tree-sitter 点位。
- `new_end_byte` 仍由起始字节加插入文本字节长度得到。
- `new_end_point` 仍由 `CalculateEndPoint` 计算。
文档内容在应用变更前传入,因此所有旧范围坐标都基于变更前文本解析。
## 测试设计
在现有 `test_provider` 测试程序中新增独立的文本坐标测试模块,避免复制整套
协议 Module 构建配置。测试至少覆盖:
- ASCII 位置的字节偏移和 Tree-sitter 点位。
- BMP 中文字符消耗一个 UTF-16 code unit、三个 UTF-8 字节。
- emoji 消耗两个 UTF-16 code unit、四个 UTF-8 字节。
- emoji 后的位置不会额外越过后续 ASCII 字符。
- 第二行位置生成正确的绝对偏移、行号和字节列。
- 行尾、文档末尾和超出范围的位置安全收敛。
- surrogate pair 中间的位置不会落入 UTF-8 序列中间。
- `CalculateEndPoint` 对多字节文本和换行继续产生 Tree-sitter 字节坐标。
测试先在当前实现上失败,再修改产品代码并确认通过。随后构建 `tsl-server`
`test_provider`,运行 `test_provider` 以及 LSP JSON 测试,确认增量同步和
协议启动行为未回归。
## 完成条件
- `ToOffset` 对 UTF-16 BMP 与 supplementary-plane 字符返回正确 UTF-8
字节偏移。
- 增量编辑的所有 `TSInputEdit` 字节位置与点位单位一致。
- 不再存在只接收 LSP `Position` 的 Tree-sitter 点位转换接口。
- 新增坐标回归测试通过,相关服务器和协议测试通过。
@@ -0,0 +1,243 @@
# LSP Core 生命周期、调度与传输设计
## 背景
`lsp.core.server` 当前用两个布尔值表达服务器生命周期,导致初始化前可以
`shutdown`、可以重复 `initialize`,且 `shutdown` 响应后立即结束主循环,
没有按 LSP 要求等待 `exit`。普通请求在输入线程同步执行,因此服务器执行
Provider 时无法读取 `$/cancelRequest`;现有取消入口没有实际取消逻辑。
消息入口和 Dispatcher 还存在几类协议边界问题:非法 JSON 与非法请求只记录
日志,Provider 异常没有生成请求错误响应,依赖缺失或序列化失败使用 `"{}"`
伪响应,`Content-Length` 解析宽松且没有消息大小上限。生命周期 callback 在
锁内执行,也允许重入死锁。诊断发布器虽然暂未启用,但会把 Tree-sitter 的
UTF-8 字节列直接作为 LSP UTF-16 character。
本设计直接建立严格、单一的 LSP 3.17 行为,不保留原 API、旧生命周期行为、
宽松 framing、Provider 形式的 `exit`/`$/cancelRequest` 或任何兼容适配层。
## 目标
- 用显式状态机约束 `initialize``shutdown``exit`
- 正常关闭等待 `exit`,并向进程入口返回准确退出码。
- 让输入线程在普通请求执行期间继续读取取消通知。
- 按请求实例精确取消,区分整数 ID 与字符串 ID。
- 为请求解析、状态、Provider 异常和取消生成规范 JSON-RPC 错误。
- 让 Dispatcher 构造后立即有效,不存在后注入依赖或伪响应。
- 严格验证 LSP framing,遇到不可恢复的传输错误立即终止。
- 删除已无必要的生命周期 callback 及其锁内调用路径。
- 将诊断位置转换为 LSP UTF-16 坐标。
- 删除无调用函数、重复错误构造和无效兜底代码。
## 非目标
- 不兼容旧的 `LspServer::Run()`、Dispatcher 默认构造或依赖 setter。
- 不接受非 LSP 3.17 framing,也不尝试从流错位中恢复。
- 不为已删除的 `shutdown``exit``cancelRequest` Provider 保留转发层。
- 不保证强制中断任意 Provider;运行中取消仍遵循 `std::stop_token` 的协作式
语义。
- 不启用当前关闭的诊断发布能力,只保证其坐标在以后启用时正确。
## 生命周期所有权
生命周期只由 `LspServer` 管理,不再由 Provider 通过 callback 间接修改。
删除 `ServerLifecycleEvent``LifecycleCallback`、Dispatcher 的生命周期 callback
注册与通知,以及 `ExecutionContext::TriggerLifecycleEvent``initialize`
Provider 只负责初始化参数、manager 状态和能力响应;`shutdown``exit`
`$/cancelRequest` 都由 core 直接处理,不再注册为 Provider。
服务器使用以下状态:
```cpp
enum class ServerState
{
kUninitialized,
kRunning,
kShutdownRequested,
kExiting,
};
```
状态只在输入线程修改,因此不使用原子布尔值:
| 当前状态 | 输入 | 行为 | 新状态 |
| --- | --- | --- | --- |
| `kUninitialized` | `initialize` | 同步执行并发送成功响应 | `kRunning` |
| `kUninitialized` | 其他请求 | `ServerNotInitialized` | 不变 |
| `kUninitialized` | `exit` | 结束主循环,返回 1 | `kExiting` |
| `kRunning` | 重复 `initialize` | `InvalidRequest` | 不变 |
| `kRunning` | `shutdown` | 取消并等待普通请求,关闭 manager,发送响应 | `kShutdownRequested` |
| `kRunning` | `exit` | 结束主循环,返回 1 | `kExiting` |
| `kShutdownRequested` | `exit` | 结束主循环,返回 0 | `kExiting` |
| `kShutdownRequested` | 请求 | `InvalidRequest` | 不变 |
| `kShutdownRequested` | 其他通知 | 忽略并记录 | 不变 |
`initialize` 只有在 Provider 正常返回并成功发送响应后才转换状态;异常或错误
响应不推进状态。`shutdown` 完成请求排空和 manager shutdown 并成功发送响应后
才转换状态。`initialized` 只在 `kRunning` 接受。EOF、短消息体和 framing 错误
均属于非正常退出,返回 1。
`LspServer::Run()` 改为返回 `int`。launcher 直接使用该退出码,不再在
`Run()` 返回后无条件报告“正常停止”。`exit` Provider 中的 sleep 和
`std::exit()` 被删除,进程退出统一沿 `Run()` → launcher → `main()` 返回。
无论收到正常或异常 `exit`,core 都先取消并等待仍活动的普通请求,再从
`Run()` 返回;异常 EOF/framing 路径执行相同的任务收尾,但不把它误报为正常
关闭。
## 请求执行与取消
`initialize``shutdown` 在输入线程同步执行,因为它们改变全局生命周期。
普通请求提交给 `AsyncExecutor``exit``$/cancelRequest` 是 core 控制消息,
在输入线程立即处理;其他通知仍按输入顺序同步分发,避免 `didOpen`
`didChange` 与后续请求发生人为重排。
请求键保留 ID 类型:
- 整数 ID `1` 映射为 `i:1`
- 字符串 ID `"1"` 映射为 `s:1`
任务 ID 使用 core 专属前缀,例如 `lsp-request:i:1`,避免与 Provider 自己提交
的后台任务冲突。服务器在 mutex 保护的映射中保存
`request key -> shared request state`,其中包含该请求的 `TaskHandle`。输入线程
先登记 request state,再调用 `Submit`,并在重新读取输入前绑定返回的 handle;
完成 callback 按 state 身份注销映射。这样快速完成或同步提交失败都不会造成
“callback 先注销、输入线程后插入”的陈旧句柄,也不需要持有映射锁调用
`Submit`。同一类型、同一 ID 尚未完成时再次出现,按 `InvalidRequest` 拒绝,
不替换或误取消旧请求。
异步任务调用 Dispatcher 并返回序列化响应。callback 根据 `TaskResult` 统一
收尾:
- `kCompleted`:发送 Provider 响应。
- `kCancelled`:发送 `RequestCancelled`
- `kFailed`:记录原始异常并发送 `InternalError`
`$/cancelRequest` 严格反序列化 `CancelParams`,按带类型的键查找当前句柄并
调用 `TaskHandle::Cancel()`。找不到或任务已经完成时只记录,不生成通知响应。
Dispatcher 将当前请求的 `std::stop_token` 放入 `ExecutionContext`;可协作
取消的 Provider 和 manager 从 context 取得并向长循环传递。任务开始前、
Provider 返回后都由 `AsyncExecutor` 再检查停止状态,保证已取消请求不会发送
成功结果。
收到合法 `shutdown` 时,服务器先复制全部活动请求句柄,在锁外逐个请求取消
并等待 callback 完成,再直接调用 `ManagerHub::Shutdown()` 并构造 null result
响应。这样 manager shutdown 不会与普通请求继续访问共享状态并发发生。等待
期间不持有请求映射锁或输出锁。
## Dispatcher 与错误构造
`RequestDispatcher` 构造函数强制接收 `AsyncExecutor&``ManagerHub&`
`LspServer``ManagerHub``AsyncExecutor`、Dispatcher 的依赖顺序声明和构造
成员。删除默认构造、`SetRequestScheduler``SetManagerHub`、空指针检查和
`"{}"` 返回值。
Dispatcher 只负责查找 Provider、创建 `ExecutionContext` 并调用 Provider
- 未注册请求生成 `MethodNotFound`
- 未注册通知只记录日志。
- Provider 异常不在 Dispatcher 中吞掉,交给请求任务 callback 转换为
`InternalError`;通知入口捕获异常并只记录日志。
错误响应由一个公共构造函数生成,接收可选 Request ID、错误码和消息。
Provider 与 server 共用该实现,删除 `SendError`
`BuildErrorResponseMessage` 的重复组装逻辑。已识别请求回显其 ID;无法识别
ID 时序列化为 `null`
错误响应自身序列化失败视为内部不变量破坏并抛出,不返回 `"{}"`、手写固定
JSON 或空字符串。外层只在能够构造规范响应时继续运行,不增加第二套序列化
兜底。
## JSON-RPC 消息验证
`HandleMessage` 先区分 JSON 语法错误与消息结构错误:
| 输入 | 处理 |
| --- | --- |
| 非法 JSON | `ParseError`ID 为 `null` |
| 合法 JSON,但顶层不是对象 | `InvalidRequest`ID 为 `null` |
| JSON 对象但不满足 JSON-RPC 2.0 消息结构 | `InvalidRequest` |
| 请求可识别 ID,但 Request 反序列化失败 | `InvalidRequest`,回显有效 ID |
| 合法请求的 Provider 抛异常 | `InternalError`,回显请求 ID |
| 合法通知的 Provider 抛异常 | 只记录,不发送响应 |
| 合法客户端响应 | 记录或交给现有响应入口,不回送响应 |
请求、通知和响应必须显式携带 `"jsonrpc":"2.0"`。Request ID 只接受协议已
定义的整数或字符串。响应必须恰好包含 `result``error` 之一。无法可靠
分类为请求的非法客户端响应不触发响应,避免形成 response-to-response 循环。
## 严格 framing
输入只接受 LSP 3.17 header/body framing
- header 行和 header 结束符必须使用 `\r\n`
- 必须且只能出现一个 `Content-Length`
- 长度使用 `std::from_chars` 解析十进制数字,并要求完整消费字段值。
- 长度必须大于 0 且不超过 16 MiB。
- 可接受规范定义的可选
`Content-Type: application/vscode-jsonrpc; charset=utf-8`;未知、重复或格式
错误字段视为 framing 错误,不接受旧 charset 拼写。
- 消息体必须精确读满声明长度。
读取结果明确区分“完整消息”“EOF”和“致命 framing 错误”。EOF 或致命错误
立即退出主循环,不 sleep、不继续扫描、不尝试重新同步流。输出继续由单一
mutex 串行写入并检查 stream 状态;同步写失败直接向 launcher 传播,异步
callback 写失败记录 fatal I/O 状态,主循环在下一控制点以 1 退出。
## Diagnostics 坐标
`lsp.utils.text_coordinates` 增加 Tree-sitter UTF-8 字节点位到 LSP UTF-16
`Position` 的反向转换。转换按目标行扫描完整 UTF-8 字符,并累计 UTF-16 code
unit;目标字节列落在多字节字符内部时收敛到该字符起点,超过行尾时收敛到
行尾。
`PublishDiagnostics` 使用该接口分别转换语法错误起止位置,不再直接复制
`start_column`/`end_column`。转换复用已有 UTF-8 解码规则,不在 server 中
维护第二套编码逻辑。
## 清理范围
- 删除 `RequiresSyncProcessing``CanProcessRequest`,状态策略只保留一个
实际调用入口。
- 删除 Dispatcher 生命周期 callback 及其 mutex。
- 删除不再注册且由 core 直接处理的 `provider/shutdown``provider/exit`
`provider/cancel_request` Module,并同步 Provider registry 与 CMake Module
列表。
- 删除 initialize Provider 的生命周期事件代码。
- 合并错误响应构造,删除 `"{}"`、固定 JSON、空响应和 sleep 重试兜底。
- `SetupLogger` 保持独立函数;它职责明确,不属于本次 core 重构。
## 测试设计
测试遵循 TDD,每项先在旧实现上观察预期失败:
- initialize 前普通请求返回 `ServerNotInitialized`
- initialize 前 shutdown 被拒绝;重复 initialize 被拒绝。
- shutdown 响应后仍等待 exit。
- `shutdown -> exit` 返回 0,直接 exit、shutdown 后 EOF 和普通 EOF 返回 1。
- 非法 JSON 返回 `ParseError`,非法 JSON-RPC 对象返回 `InvalidRequest`
- 未注册请求返回 `MethodNotFound`
- 抛异常请求返回 `InternalError`;抛异常通知不产生响应。
- 普通请求不会阻塞主循环读取 `$/cancelRequest`
- 取消整数 ID 与字符串 ID 不冲突,取消只影响对应请求实例。
- 已取消请求返回 `RequestCancelled`,完成请求不会被迟到取消改写。
- shutdown 取消并等待所有活动普通请求。
- 缺失、重复、非数字、部分数字、零、超限 `Content-Length` 和短消息体导致
非零退出;规范消息仍可连续读取。
- callback 重入不再存在死锁路径,因为生命周期 callback 机制已删除。
- 中文和 emoji 前后的 diagnostic character 使用 UTF-16 code unit。
优先扩展 `test_provider` 和服务器进程级 JSON 测试,直接验证真实 framing、
响应和退出码。完成后构建 `tsl-server`,运行 scheduler、provider 与 LSP JSON
相关测试。已知无关的 AST、symbol、semantic 脚本失败不作为本任务的通过依据。
## 完成条件
- 生命周期只能沿设计表转换,退出码与关闭顺序一致。
- 普通请求异步执行,取消通知能在请求运行期间被读取并精确定位任务。
- 所有请求错误都产生合法 JSON-RPC 响应,通知错误不产生响应。
- Dispatcher 不存在无效中间状态或伪响应。
- framing 错误不会进入 sleep/重试/继续读取分支。
- diagnostics 使用 UTF-16 character。
- core 中不存在原两个状态 bool、未使用检查函数、空取消分支、生命周期 callback
`"{}"`/固定 JSON 兜底。
- 相关构建和测试通过,并完成一次针对无效兜底与临时兼容层的专项复审。
+1 -1
View File
@@ -1,5 +1,5 @@
CompileFlags:
CompilationDatabase: build/clang-linux/Release
CompilationDatabase: /var/tmp/tsl-devkit/clang-linux/Release
---
If:
+10 -8
View File
@@ -17,13 +17,15 @@ fmt/*:header_only=True
cmake_layout
# 使用 Clang+libc++ 构建(Ninja generator):
# 构建产物统一放到 /var/tmp/tsl-devkit 下(跨机器固定绝对路径,
# .clangd 的 CompilationDatabase 指向同一位置)。
#
# Linux 环境(build/clang-linux):
# CONAN_HOME=/tmp/conan-home conan install . -pr:h=conan/profiles/linux-x86_64-clang -pr:b=conan/profiles/linux-x86_64-clang -of build/clang-linux --build=missing
# cmake -S . -B build/clang-linux/Release -DCMAKE_TOOLCHAIN_FILE=$PWD/build/clang-linux/Release/generators/conan_toolchain.cmake -DBUILD_TESTS=ON
# cmake --build build/clang-linux/Release --target tsl-server
# Linux 环境(/var/tmp/tsl-devkit/clang-linux):
# CONAN_HOME=/var/tmp/tsl-devkit/conan-home conan install . -pr:h=conan/profiles/linux-x86_64-clang -pr:b=conan/profiles/linux-x86_64-clang -of /var/tmp/tsl-devkit/clang-linux --build=missing
# cmake -S . -B /var/tmp/tsl-devkit/clang-linux/Release -DCMAKE_TOOLCHAIN_FILE=/var/tmp/tsl-devkit/clang-linux/Release/generators/conan_toolchain.cmake -DBUILD_TESTS=ON
# cmake --build /var/tmp/tsl-devkit/clang-linux/Release --target tsl-server
#
# Windows 交叉编译(build/clang-cross):
# CONAN_HOME=/tmp/conan-home conan install . -pr:b=conan/profiles/linux-x86_64-clang -pr:h=conan/profiles/windows-x86_64-clang-cross -of build/clang-cross --build=missing
# cmake -S . -B build/clang-cross -DCMAKE_TOOLCHAIN_FILE=$PWD/build/clang-cross/Release/generators/conan_toolchain.cmake -DBUILD_TESTS=OFF
# cmake --build build/clang-cross --target tsl-server
# Windows 交叉编译(/var/tmp/tsl-devkit/clang-cross):
# CONAN_HOME=/var/tmp/tsl-devkit/conan-home conan install . -pr:b=conan/profiles/linux-x86_64-clang -pr:h=conan/profiles/windows-x86_64-clang-cross -of /var/tmp/tsl-devkit/clang-cross --build=missing
# cmake -S . -B /var/tmp/tsl-devkit/clang-cross/Release -DCMAKE_TOOLCHAIN_FILE=/var/tmp/tsl-devkit/clang-cross/Release/generators/conan_toolchain.cmake -DBUILD_TESTS=OFF
# cmake --build /var/tmp/tsl-devkit/clang-cross/Release --target tsl-server
+48 -14
View File
@@ -6,41 +6,75 @@ import spdlog;
import std;
import lsp.core.server;
import lsp.provider.manifest;
import lsp.utils.args_parser;
namespace
{
void SetupLogger(const lsp::utils::ServerConfig& config)
{
spdlog::set_pattern("%Y-%m-%d %H:%M:%S.%e [%t] [%^%l%$] %v");
auto logger = config.log_file.empty() ? spdlog::stderr_logger_mt("console_logger") : spdlog::basic_logger_mt("file_logger", config.log_file);
logger->set_level(config.log_level);
spdlog::set_default_logger(logger);
spdlog::set_level(config.log_level);
}
}
export int Run(int argc, char* argv[])
{
lsp::utils::ArgsParser& args_parser = lsp::utils::ArgsParser::Instance();
auto& config = args_parser.Parse(argc, argv);
if (config.show_help)
auto parsed = lsp::utils::ParseArgs(argc, argv);
if (!parsed)
{
lsp::utils::ArgsParser::PrintHelp(argv[0]);
std::cerr << "[TSL-LSP] Argument error: " << parsed.error() << '\n';
return 2;
}
if (parsed->action == lsp::utils::ParseAction::kShowHelp)
{
lsp::utils::PrintHelp(std::cout, argv[0]);
return 0;
}
lsp::utils::ArgsParser::SetupLogger(config);
const auto& config = parsed->config;
try
{
SetupLogger(config);
}
catch (const std::exception& error)
{
std::cerr << "[TSL-LSP] Failed to initialize logger: " << error.what() << '\n';
return 1;
}
int exit_code = 1;
try
{
spdlog::info("TSL-LSP server starting...");
lsp::core::LspServer server(config.thread_count, config.interpreter_path);
server.Run();
lsp::core::LspServer server(
std::cin,
std::cout,
lsp::provider::RegisterAllProviders,
config.thread_count,
config.interpreter_path);
exit_code = server.Run();
}
catch (const std::exception& e)
catch (const std::exception& error)
{
std::cerr << "[TSL-LSP] Server fatal error: " << e.what() << std::endl;
spdlog::error("Server fatal error: {}", e.what());
std::cerr << "[TSL-LSP] Server fatal error: " << error.what() << '\n';
spdlog::error("Server fatal error: {}", error.what());
spdlog::shutdown();
return 1;
}
catch (...)
{
std::cerr << "[TSL-LSP] Server unknown fatal error" << std::endl;
std::cerr << "[TSL-LSP] Server unknown fatal error\n";
spdlog::error("Server unknown fatal error");
spdlog::shutdown();
return 1;
}
spdlog::info("TSL-LSP server stopped normally");
spdlog::info("TSL-LSP server stopped with exit code {}", exit_code);
spdlog::shutdown();
return 0;
return exit_code;
}
+122 -146
View File
@@ -1,30 +1,19 @@
module;
export module lsp.core.dispatcher;
import spdlog;
import spdlog;
import std;
import lsp.protocol.types;
import lsp.codec.facade;
import lsp.scheduler.async_executor;
import lsp.manager.manager_hub;
import lsp.protocol.types;
import lsp.scheduler.async_executor;
namespace transform = lsp::codec;
export namespace lsp::core
{
enum class ServerLifecycleEvent
{
kInitializing,
kInitialized,
kInitializeFailed,
kShuttingDown,
kShutdown
};
using LifecycleCallback = std::function<void(ServerLifecycleEvent)>;
class IProvider
{
public:
@@ -33,62 +22,75 @@ export namespace lsp::core
virtual std::string GetProviderName() const = 0;
};
class ExecutionContext;
class IRequestProvider : public IProvider
{
public:
virtual ~IRequestProvider() = default;
virtual std::string ProvideResponse(const protocol::RequestMessage& request, class ExecutionContext& execution_context) = 0;
virtual std::string ProvideResponse(const protocol::RequestMessage& request,
ExecutionContext& execution_context) = 0;
};
class INotificationProvider : public IProvider
{
public:
virtual ~INotificationProvider() = default;
virtual void HandleNotification(const protocol::NotificationMessage& notification, class ExecutionContext& execution_context) = 0;
virtual void HandleNotification(const protocol::NotificationMessage& notification,
ExecutionContext& execution_context) = 0;
};
class ExecutionContext
{
public:
ExecutionContext(LifecycleCallback lifecycle_callback,
scheduler::AsyncExecutor& scheduler,
manager::ManagerHub& manager_hub) :
lifecycle_callback_(lifecycle_callback),
async_executor_(scheduler),
manager_hub_(manager_hub) {}
scheduler::AsyncExecutor& GetScheduler() const { return async_executor_; }
manager::ManagerHub& GetManagerHub() const { return manager_hub_; }
void TriggerLifecycleEvent(ServerLifecycleEvent event) const
ExecutionContext(scheduler::async_executor::AsyncExecutor& scheduler,
manager::ManagerHub& manager_hub,
std::stop_token stop_token = {}) : async_executor_(scheduler),
manager_hub_(manager_hub),
stop_token_(stop_token)
{
if (lifecycle_callback_)
lifecycle_callback_(event);
}
scheduler::async_executor::AsyncExecutor& GetScheduler() const
{
return async_executor_;
}
manager::ManagerHub& GetManagerHub() const
{
return manager_hub_;
}
std::stop_token GetStopToken() const
{
return stop_token_;
}
private:
LifecycleCallback lifecycle_callback_;
scheduler::AsyncExecutor& async_executor_;
scheduler::async_executor::AsyncExecutor& async_executor_;
manager::ManagerHub& manager_hub_;
std::stop_token stop_token_;
};
std::string BuildErrorResponseMessage(const protocol::RequestMessage& request, protocol::ErrorCodes code, const std::string& message);
std::string BuildErrorResponseMessage(std::optional<protocol::RequestId> id,
protocol::ErrorCodes code,
std::string_view message);
std::string BuildErrorResponseMessage(const protocol::RequestMessage& request,
protocol::ErrorCodes code,
std::string_view message);
class RequestDispatcher
{
public:
RequestDispatcher();
RequestDispatcher(scheduler::async_executor::AsyncExecutor& scheduler,
manager::ManagerHub& manager_hub);
~RequestDispatcher() = default;
void SetRequestScheduler(scheduler::AsyncExecutor* scheduler);
void SetManagerHub(manager::ManagerHub* manager_hub);
void RegisterRequestProvider(std::shared_ptr<IRequestProvider> provider);
void RegisterNotificationProvider(std::shared_ptr<INotificationProvider> provider);
void RegisterLifecycleCallback(LifecycleCallback callback);
std::string Dispatch(const protocol::RequestMessage& request);
std::string Dispatch(const protocol::RequestMessage& request,
std::stop_token stop_token = {});
void Dispatch(const protocol::NotificationMessage& notification);
bool SupportsRequest(const std::string& method) const;
@@ -98,99 +100,72 @@ export namespace lsp::core
std::vector<std::string> GetAllSupportedMethods() const;
private:
void NotifyAllLifecycleListeners(ServerLifecycleEvent event);
std::string HandleUnknownRequest(const protocol::RequestMessage& request);
void HandleUnknownNotification(const protocol::NotificationMessage& notification);
private:
mutable std::shared_mutex providers_mutex_;
std::unordered_map<std::string, std::shared_ptr<IRequestProvider>> providers_;
mutable std::shared_mutex notification_providers_mutex_;
std::unordered_map<std::string, std::shared_ptr<INotificationProvider>> notification_providers_;
std::unordered_map<std::string, std::shared_ptr<INotificationProvider>>
notification_providers_;
std::mutex callbacks_mutex_;
std::vector<LifecycleCallback> lifecycle_callbacks_;
LifecycleCallback context_lifecycle_callback_;
scheduler::AsyncExecutor* async_executor_ = nullptr;
manager::ManagerHub* manager_hub_ = nullptr;
scheduler::async_executor::AsyncExecutor& async_executor_;
manager::ManagerHub& manager_hub_;
};
}
namespace lsp::core
{
RequestDispatcher::RequestDispatcher()
RequestDispatcher::RequestDispatcher(
scheduler::async_executor::AsyncExecutor& scheduler,
manager::ManagerHub& manager_hub) : async_executor_(scheduler),
manager_hub_(manager_hub)
{
context_lifecycle_callback_ = [this](ServerLifecycleEvent event) {
NotifyAllLifecycleListeners(event);
};
}
void RequestDispatcher::SetRequestScheduler(scheduler::AsyncExecutor* scheduler)
void RequestDispatcher::RegisterRequestProvider(
std::shared_ptr<IRequestProvider> provider)
{
async_executor_ = scheduler;
spdlog::debug("Request scheduler set");
}
void RequestDispatcher::SetManagerHub(manager::ManagerHub* manager_hub)
{
manager_hub_ = manager_hub;
spdlog::debug("Manager hub bound to dispatcher");
}
void RequestDispatcher::RegisterRequestProvider(std::shared_ptr<IRequestProvider> provider)
{
std::unique_lock<std::shared_mutex> lock(providers_mutex_);
std::unique_lock lock(providers_mutex_);
std::string method = provider->GetMethod();
providers_[method] = provider;
providers_[method] = std::move(provider);
}
void RequestDispatcher::RegisterNotificationProvider(std::shared_ptr<INotificationProvider> provider)
void RequestDispatcher::RegisterNotificationProvider(
std::shared_ptr<INotificationProvider> provider)
{
std::unique_lock<std::shared_mutex> lock(notification_providers_mutex_);
std::unique_lock lock(notification_providers_mutex_);
std::string method = provider->GetMethod();
notification_providers_[method] = provider;
notification_providers_[method] = std::move(provider);
}
void RequestDispatcher::RegisterLifecycleCallback(LifecycleCallback callback)
std::string RequestDispatcher::Dispatch(const protocol::RequestMessage& request,
std::stop_token stop_token)
{
std::lock_guard<std::mutex> lock(callbacks_mutex_);
lifecycle_callbacks_.push_back(std::move(callback));
}
std::string RequestDispatcher::Dispatch(const protocol::RequestMessage& request)
{
std::shared_ptr<IRequestProvider> provider = nullptr;
std::shared_ptr<IRequestProvider> provider;
{
std::shared_lock<std::shared_mutex> lock(providers_mutex_);
auto it = providers_.find(request.method);
if (it != providers_.end())
provider = it->second;
std::shared_lock lock(providers_mutex_);
auto provider_it = providers_.find(request.method);
if (provider_it != providers_.end())
provider = provider_it->second;
}
if (!provider)
return HandleUnknownRequest(request);
if (!async_executor_ || !manager_hub_)
{
spdlog::error("RequestDispatcher dependencies not set");
return "{}";
}
ExecutionContext context(context_lifecycle_callback_, *async_executor_, *manager_hub_);
ExecutionContext context(async_executor_, manager_hub_, stop_token);
return provider->ProvideResponse(request, context);
}
void RequestDispatcher::Dispatch(const protocol::NotificationMessage& notification)
{
std::shared_ptr<INotificationProvider> provider = nullptr;
std::shared_ptr<INotificationProvider> provider;
{
std::shared_lock<std::shared_mutex> lock(notification_providers_mutex_);
auto it = notification_providers_.find(notification.method);
if (it != notification_providers_.end())
provider = it->second;
std::shared_lock lock(notification_providers_mutex_);
auto provider_it = notification_providers_.find(notification.method);
if (provider_it != notification_providers_.end())
provider = provider_it->second;
}
if (!provider)
@@ -199,45 +174,45 @@ namespace lsp::core
return;
}
if (!async_executor_ || !manager_hub_)
{
spdlog::error("NotificationDispatcher dependencies not set");
return;
}
ExecutionContext context(context_lifecycle_callback_, *async_executor_, *manager_hub_);
ExecutionContext context(async_executor_, manager_hub_);
provider->HandleNotification(notification, context);
}
bool RequestDispatcher::SupportsRequest(const std::string& method) const
{
std::shared_lock<std::shared_mutex> lock(providers_mutex_);
std::shared_lock lock(providers_mutex_);
return providers_.contains(method);
}
bool RequestDispatcher::SupportsNotification(const std::string& method) const
{
std::shared_lock<std::shared_mutex> lock(notification_providers_mutex_);
std::shared_lock lock(notification_providers_mutex_);
return notification_providers_.contains(method);
}
std::vector<std::string> RequestDispatcher::GetSupportedRequests() const
{
std::vector<std::string> methods;
std::shared_lock<std::shared_mutex> lock(providers_mutex_);
std::shared_lock lock(providers_mutex_);
methods.reserve(providers_.size());
for (const auto& [method, _] : providers_)
for (const auto& [method, provider] : providers_)
{
static_cast<void>(provider);
methods.push_back(method);
}
return methods;
}
std::vector<std::string> RequestDispatcher::GetSupportedNotifications() const
{
std::vector<std::string> methods;
std::shared_lock<std::shared_mutex> lock(notification_providers_mutex_);
std::shared_lock lock(notification_providers_mutex_);
methods.reserve(notification_providers_.size());
for (const auto& [method, _] : notification_providers_)
for (const auto& [method, provider] : notification_providers_)
{
static_cast<void>(provider);
methods.push_back(method);
}
return methods;
}
@@ -249,48 +224,49 @@ namespace lsp::core
return methods;
}
void RequestDispatcher::NotifyAllLifecycleListeners(ServerLifecycleEvent event)
{
std::lock_guard<std::mutex> lock(callbacks_mutex_);
for (const auto& callback : lifecycle_callbacks_)
{
if (callback)
callback(event);
}
}
std::string RequestDispatcher::HandleUnknownRequest(const protocol::RequestMessage& request)
std::string RequestDispatcher::HandleUnknownRequest(
const protocol::RequestMessage& request)
{
spdlog::warn("No request provider registered for method: {}", request.method);
protocol::ResponseMessage response;
response.id = request.id;
response.error = protocol::ResponseError{
.jsonrpc = "2.0",
.code = static_cast<protocol::integer>(protocol::ErrorCodes::MethodNotFound),
.message = "Method not supported",
.data = std::nullopt
return BuildErrorResponseMessage(
request, protocol::ErrorCodes::MethodNotFound, "Method not supported");
}
void RequestDispatcher::HandleUnknownNotification(
const protocol::NotificationMessage& notification)
{
spdlog::warn("No notification provider registered for method: {}",
notification.method);
}
std::string BuildErrorResponseMessage(std::optional<protocol::RequestId> id,
protocol::ErrorCodes code,
std::string_view message)
{
protocol::LSPAny response_id(std::nullptr_t{});
if (id)
{
response_id = std::visit(
[](const auto& value) -> protocol::LSPAny { return value; }, *id);
}
protocol::LSPObject error{
{ "code", static_cast<protocol::integer>(code) },
{ "message", protocol::string(message) },
};
auto json = transform::Serialize(response);
return json.value_or("{}");
protocol::LSPObject response{
{ "jsonrpc", protocol::string("2.0") },
{ "id", std::move(response_id) },
{ "error", std::move(error) },
};
return transform::Serialize(response).value();
}
void RequestDispatcher::HandleUnknownNotification(const protocol::NotificationMessage& notification)
std::string BuildErrorResponseMessage(const protocol::RequestMessage& request,
protocol::ErrorCodes code,
std::string_view message)
{
spdlog::warn("No notification provider registered for method: {}", notification.method);
}
std::string BuildErrorResponseMessage(const protocol::RequestMessage& request, protocol::ErrorCodes code, const std::string& message)
{
protocol::ResponseMessage response;
response.id = request.id;
protocol::ResponseError error;
error.code = static_cast<protocol::integer>(code);
error.message = message;
response.error = error;
auto json = transform::Serialize(response);
if (json.has_value())
return json.value();
spdlog::error("Failed to serialize error response.");
return R"({"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"Failed to serialize error response"}})";
return BuildErrorResponseMessage(request.id, code, message);
}
}
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -12,7 +12,7 @@ export namespace lsp::manager::bootstrap
{
void InitializeManagerHub(
ManagerHub& hub,
scheduler::AsyncExecutor& async_executor,
scheduler::async_executor::AsyncExecutor& async_executor,
const std::vector<std::string>& system_lib_paths);
}
@@ -20,7 +20,7 @@ namespace lsp::manager::bootstrap
{
void InitializeManagerHub(
ManagerHub& hub,
scheduler::AsyncExecutor& async_executor,
scheduler::async_executor::AsyncExecutor& async_executor,
const std::vector<std::string>& system_lib_paths)
{
spdlog::info("Initializing manager hub...");
@@ -31,10 +31,10 @@ namespace lsp::manager::bootstrap
async_executor.Submit(
task_name,
[&hub, path]() -> std::optional<std::string> {
[&hub, path](std::stop_token stop_token) -> std::optional<std::string> {
try
{
hub.symbols().LoadSystemLibrary(path);
hub.symbols().LoadSystemLibrary(path, stop_token);
return std::format("Loaded system library: {}", path);
}
catch (const std::exception& e)
@@ -43,11 +43,11 @@ namespace lsp::manager::bootstrap
throw;
}
},
[path](const std::optional<std::string>& result, bool cancelled) {
if (cancelled)
[path](const scheduler::async_executor::TaskResult& result) {
if (result.status == scheduler::async_executor::TaskStatus::kCancelled)
spdlog::info("System library load task cancelled: {}", path);
else if (result)
spdlog::info("{}", *result);
else if (result.status == scheduler::async_executor::TaskStatus::kCompleted && result.value)
spdlog::info("{}", *result.value);
});
}
spdlog::info("Manager hub initialized, system library loading in background");
+7 -8
View File
@@ -1,6 +1,5 @@
module;
export module lsp.manager.parser;
import tree_sitter;
import spdlog;
@@ -123,15 +122,15 @@ namespace lsp::manager
if (!tree_)
return;
protocol::uinteger start_offset = utils::text_coordinates::ToOffset(change.range.start, content);
protocol::uinteger end_offset = utils::text_coordinates::ToOffset(change.range.end, content);
const auto start = utils::text_coordinates::ToBytePosition(change.range.start, content);
const auto old_end = utils::text_coordinates::ToBytePosition(change.range.end, content);
TSInputEdit edit{};
edit.start_byte = start_offset;
edit.old_end_byte = end_offset;
edit.new_end_byte = start_offset + change.text.length();
edit.start_point = utils::text_coordinates::ToPoint(change.range.start);
edit.old_end_point = utils::text_coordinates::ToPoint(change.range.end);
edit.start_byte = start.offset;
edit.old_end_byte = old_end.offset;
edit.new_end_byte = start.offset + change.text.length();
edit.start_point = start.point;
edit.old_end_point = old_end.point;
edit.new_end_point = utils::text_coordinates::CalculateEndPoint(change.text, edit.start_point);
ts_tree_edit(tree_.get(), &edit);
+62 -10
View File
@@ -37,11 +37,11 @@ export namespace lsp::manager
explicit Symbol(EventBus& event_bus);
~Symbol();
void LoadSystemLibrary(const std::string& lib_path);
void LoadWorkspace(const protocol::DocumentUri& workspace_uri);
void LoadSystemLibrary(const std::string& lib_path, std::stop_token stop_token = {});
void LoadWorkspace(const protocol::DocumentUri& workspace_uri, std::stop_token stop_token = {});
void IndexWorkspaceFiles(const std::vector<protocol::DocumentUri>& uris);
void RemoveWorkspaceFiles(const std::vector<protocol::DocumentUri>& uris);
void IndexWorkspaceFiles(const std::vector<protocol::DocumentUri>& uris, std::stop_token stop_token = {});
void RemoveWorkspaceFiles(const std::vector<protocol::DocumentUri>& uris, std::stop_token stop_token = {});
void RenameWorkspaceFiles(const std::vector<std::pair<protocol::DocumentUri, protocol::DocumentUri>>& files);
const language::symbol::SymbolTable* GetSymbolTable(const protocol::DocumentUri& uri) const;
@@ -306,11 +306,16 @@ namespace lsp::manager
Symbol::~Symbol() = default;
void Symbol::LoadSystemLibrary(const std::string& lib_path)
void Symbol::LoadSystemLibrary(const std::string& lib_path, std::stop_token stop_token)
{
spdlog::info("Loading system library from: {}", lib_path);
auto start = std::chrono::steady_clock::now();
if (stop_token.stop_requested())
{
return;
}
if (!std::filesystem::exists(lib_path))
{
spdlog::warn("System library path does not exist: {}", lib_path);
@@ -326,6 +331,11 @@ namespace lsp::manager
for (const auto& entry : std::filesystem::recursive_directory_iterator(lib_path, options))
{
if (stop_token.stop_requested())
{
return;
}
if (!entry.is_regular_file())
continue;
@@ -338,6 +348,10 @@ namespace lsp::manager
spdlog::trace("Indexing library file: {}", entry.path().string());
auto table = BuildSymbolTableFromFile(entry.path());
if (stop_token.stop_requested())
{
return;
}
if (!table)
{
spdlog::trace("Failed to build symbol table for: {}", entry.path().string());
@@ -362,6 +376,11 @@ namespace lsp::manager
++loaded;
}
if (stop_token.stop_requested())
{
return;
}
{
std::unique_lock<std::shared_mutex> lock(mutex_);
system_symbols_ = std::move(new_symbols);
@@ -378,12 +397,17 @@ namespace lsp::manager
duration);
}
void Symbol::LoadWorkspace(const protocol::DocumentUri& workspace_uri)
void Symbol::LoadWorkspace(const protocol::DocumentUri& workspace_uri, std::stop_token stop_token)
{
auto workspace_path = UriToPath(workspace_uri);
spdlog::info("Loading workspace from: {}", workspace_path);
auto start = std::chrono::steady_clock::now();
if (stop_token.stop_requested())
{
return;
}
if (!std::filesystem::exists(workspace_path))
{
spdlog::warn("Workspace path does not exist: {}", workspace_path);
@@ -399,6 +423,11 @@ namespace lsp::manager
for (const auto& entry : std::filesystem::recursive_directory_iterator(workspace_path, options))
{
if (stop_token.stop_requested())
{
return;
}
if (!entry.is_regular_file())
continue;
@@ -407,6 +436,10 @@ namespace lsp::manager
continue;
auto table = BuildSymbolTableFromFile(entry.path());
if (stop_token.stop_requested())
{
return;
}
if (!table)
{
++failed;
@@ -431,6 +464,11 @@ namespace lsp::manager
++loaded;
}
if (stop_token.stop_requested())
{
return;
}
{
std::unique_lock<std::shared_mutex> lock(mutex_);
workspace_symbols_ = std::move(new_symbols);
@@ -447,7 +485,7 @@ namespace lsp::manager
duration);
}
void Symbol::IndexWorkspaceFiles(const std::vector<protocol::DocumentUri>& uris)
void Symbol::IndexWorkspaceFiles(const std::vector<protocol::DocumentUri>& uris, std::stop_token stop_token)
{
std::unordered_map<std::string, StoredSymbolEntry> updates;
std::vector<std::string> removals;
@@ -457,6 +495,11 @@ namespace lsp::manager
for (const auto& uri : uris)
{
if (stop_token.stop_requested())
{
return;
}
auto file_path = std::filesystem::path(UriToPath(uri));
auto kind = GetTslFileKind(file_path);
if (kind == TslFileKind::kOther)
@@ -473,6 +516,10 @@ namespace lsp::manager
}
auto table = BuildSymbolTableFromFile(file_path);
if (stop_token.stop_requested())
{
return;
}
if (!table)
{
removals.push_back(std::move(normalized_uri));
@@ -494,7 +541,7 @@ namespace lsp::manager
updates[normalized_uri] = std::move(stored);
}
if (updates.empty() && removals.empty())
if (stop_token.stop_requested() || (updates.empty() && removals.empty()))
{
return;
}
@@ -513,7 +560,7 @@ namespace lsp::manager
}
}
void Symbol::RemoveWorkspaceFiles(const std::vector<protocol::DocumentUri>& uris)
void Symbol::RemoveWorkspaceFiles(const std::vector<protocol::DocumentUri>& uris, std::stop_token stop_token)
{
if (uris.empty())
{
@@ -525,6 +572,11 @@ namespace lsp::manager
for (const auto& uri : uris)
{
if (stop_token.stop_requested())
{
return;
}
auto file_path = std::filesystem::path(UriToPath(uri));
auto kind = GetTslFileKind(file_path);
if (kind == TslFileKind::kOther)
@@ -534,7 +586,7 @@ namespace lsp::manager
removals.push_back(PathToUri(file_path));
}
if (removals.empty())
if (stop_token.stop_requested() || removals.empty())
{
return;
}
@@ -39,7 +39,6 @@ export namespace lsp::protocol
struct ResponseError
{
string jsonrpc = "2.0";
integer code;
string message;
std::optional<LSPAny> data;
+1 -1
View File
@@ -50,7 +50,7 @@ namespace glz
struct meta<lsp::protocol::ResponseError>
{
using T = lsp::protocol::ResponseError;
static constexpr auto value = glz::object(&T::jsonrpc, &T::code, &T::message, &T::data);
static constexpr auto value = glz::object(&T::code, &T::message, &T::data);
};
template<>
@@ -4,13 +4,10 @@ export module lsp.provider.base.interface;
import std;
// Thin bridge to core dispatcher interfaces and aliases for backward compatibility.
export import lsp.core.dispatcher;
export namespace lsp::provider
{
using ServerLifecycleEvent = lsp::core::ServerLifecycleEvent;
using LifecycleCallback = lsp::core::LifecycleCallback;
using ExecutionContext = lsp::core::ExecutionContext;
using IProvider = lsp::core::IProvider;
using IRequestProvider = lsp::core::IRequestProvider;
@@ -1,45 +0,0 @@
module;
export module lsp.provider.cancel_request.cancel_request;
import spdlog;
import std;
import lsp.protocol.types;
import lsp.codec.facade;
import lsp.provider.base.interface;
namespace transform = lsp::codec;
export namespace lsp::provider
{
class CancelRequest : public AutoRegisterProvider<CancelRequest, INotificationProvider>
{
public:
static constexpr std::string_view kMethod = "$/cancelRequest";
static constexpr std::string_view kProviderName = "CancelRequest";
CancelRequest() = default;
void HandleNotification(const protocol::NotificationMessage& notification, ExecutionContext& context) override;
};
}
namespace lsp::provider
{
void CancelRequest::HandleNotification(const protocol::NotificationMessage& notification, ExecutionContext& context)
{
spdlog::debug("CancelRequestProvider received {}", notification.method);
auto params = transform::FromLSPAny.template operator()<protocol::CancelParams>(notification.params.value());
std::string id_to_cancel = transform::debug::GetIdString(params.id);
spdlog::debug("Processing cancel request for ID: {}", id_to_cancel);
auto& scheduler = context.GetScheduler();
bool cancelled = scheduler.Cancel(id_to_cancel);
spdlog::debug("Cancel request {} {}", id_to_cancel, cancelled ? "succeeded" : "not found");
}
}
-47
View File
@@ -1,47 +0,0 @@
module;
export module lsp.provider.exit.exit;
import spdlog;
import std;
import lsp.provider.base.interface;
import lsp.protocol.types;
export namespace lsp::provider
{
class Exit : public AutoRegisterProvider<Exit, INotificationProvider>
{
public:
static constexpr std::string_view kMethod = "exit";
static constexpr std::string_view kProviderName = "Exit";
Exit() = default;
void HandleNotification(const protocol::NotificationMessage& notification, ExecutionContext& context) override;
};
}
namespace lsp::provider
{
void Exit::HandleNotification(const protocol::NotificationMessage& notification, ExecutionContext& context)
{
spdlog::debug("Exit notification {}", notification.method);
spdlog::info("Exit notification received");
// 触发生命周期事件
context.TriggerLifecycleEvent(ServerLifecycleEvent::kShuttingDown);
// 给一些时间完成清理
std::this_thread::sleep_for(std::chrono::milliseconds(100));
context.TriggerLifecycleEvent(ServerLifecycleEvent::kShutdown);
std::exit(0);
}
}
@@ -27,11 +27,6 @@ export namespace lsp::provider
protocol::InitializeResult BuildInitializeResult();
protocol::TextDocumentSyncOptions BuildTextDocumentSyncOptions();
protocol::CompletionOptions BuildCompletionOptions();
protocol::SignatureHelpOptions BuildSignatureHelpOptions();
protocol::SemanticTokensOptions BuildSemanticTokenOptions();
protocol::CodeActionOptions BuildCodeActionOptions();
protocol::DocumentOnTypeFormattingOptions BuildOnTypeFormattingOptions();
protocol::ExecuteCommandOptions BuildExecuteCommandOptions();
void ProcessWorkspaceFolder(const std::vector<protocol::WorkspaceFolder>& workspace_folders, ExecutionContext& context);
};
@@ -39,9 +34,6 @@ export namespace lsp::provider
namespace lsp::provider
{
std::string Initialize::ProvideResponse(const protocol::RequestMessage& request, ExecutionContext& context)
{
@@ -63,11 +55,7 @@ namespace lsp::provider
response.result = transform::ToLSPAny(BuildInitializeResult());
std::optional<std::string> json = transform::Serialize(response);
if (!json.has_value())
{
context.TriggerLifecycleEvent(ServerLifecycleEvent::kInitializeFailed);
return BuildErrorResponseMessage(request, protocol::ErrorCodes::InternalError, "Internal error");
}
context.TriggerLifecycleEvent(ServerLifecycleEvent::kInitialized);
throw std::runtime_error("Failed to serialize initialize response");
return json.value();
}
@@ -79,54 +67,6 @@ namespace lsp::provider
result.capabilities.textDocumentSync = BuildTextDocumentSyncOptions();
result.capabilities.completionProvider = BuildCompletionOptions();
result.capabilities.definitionProvider = true;
// NOTE: Only advertise the minimal feature set for now:
// - completion (with resolve)
// - go-to-definition
//
// Staged rollout: keep other capabilities disabled. Uncomment as features are re-enabled.
// result.capabilities.typeDefinitionProvider = true;
// result.capabilities.implementationProvider = true;
// result.capabilities.hoverProvider = true;
// result.capabilities.signatureHelpProvider = BuildSignatureHelpOptions();
// result.capabilities.codeActionProvider = BuildCodeActionOptions();
// result.capabilities.codeLensProvider = protocol::CodeLensOptions{ .resolveProvider = true };
// result.capabilities.documentLinkProvider = protocol::DocumentLinkOptions{ .resolveProvider = true };
// result.capabilities.colorProvider = true;
// result.capabilities.referencesProvider = true;
// result.capabilities.documentHighlightProvider = true;
// result.capabilities.renameProvider = protocol::RenameOptions{ .prepareProvider = true };
// result.capabilities.documentSymbolProvider = true;
// result.capabilities.workspaceSymbolProvider = protocol::WorkspaceSymbolOptions{ .resolveProvider = true };
// result.capabilities.semanticTokensProvider = BuildSemanticTokenOptions();
// result.capabilities.callHierarchyProvider = true;
// result.capabilities.typeHierarchyProvider = true;
// result.capabilities.inlayHintProvider = protocol::InlayHintOptions{ .resolveProvider = true };
// result.capabilities.foldingRangeProvider = true;
// result.capabilities.selectionRangeProvider = true;
// result.capabilities.linkedEditingRangeProvider = true;
// result.capabilities.monikerProvider = true;
// result.capabilities.inlineValueProvider = true;
// result.capabilities.documentFormattingProvider = true;
// result.capabilities.documentRangeFormattingProvider = true;
// result.capabilities.documentOnTypeFormattingProvider = BuildOnTypeFormattingOptions();
// result.capabilities.executeCommandProvider = BuildExecuteCommandOptions();
//
// protocol::DiagnosticOptions diagnostic;
// diagnostic.identifier = "syntax";
// diagnostic.interFileDependencies = false;
// diagnostic.workspaceDiagnostics = true;
// result.capabilities.diagnosticProvider = std::move(diagnostic);
//
// protocol::ServerCapabilities::Workspace workspace;
// protocol::ServerCapabilities::Workspace::FileOperations file_operations;
// file_operations.didCreate = true;
// file_operations.willCreate = true;
// file_operations.didDelete = true;
// file_operations.willDelete = true;
// file_operations.didRename = true;
// file_operations.willRename = true;
// workspace.fileOperations = std::move(file_operations);
// result.capabilities.workspace = std::move(workspace);
return result;
}
@@ -148,70 +88,6 @@ namespace lsp::provider
return options;
}
protocol::SignatureHelpOptions Initialize::BuildSignatureHelpOptions()
{
protocol::SignatureHelpOptions options;
options.triggerCharacters = { std::vector<std::string>{ "(", "," } };
options.retriggerCharacters = { std::vector<std::string>{ "(", "," } };
return options;
}
protocol::CodeActionOptions Initialize::BuildCodeActionOptions()
{
protocol::CodeActionOptions options;
options.codeActionKinds = std::vector<protocol::CodeActionKind>{
protocol::CodeActionKindLiterals::QuickFix,
protocol::CodeActionKindLiterals::SourceFixAll,
};
options.resolveProvider = true;
return options;
}
protocol::DocumentOnTypeFormattingOptions Initialize::BuildOnTypeFormattingOptions()
{
protocol::DocumentOnTypeFormattingOptions options;
options.firstTriggerCharacter = ";";
options.moreTriggerCharacter = std::vector<protocol::string>{ "\n" };
return options;
}
protocol::ExecuteCommandOptions Initialize::BuildExecuteCommandOptions()
{
protocol::ExecuteCommandOptions options;
options.commands = std::vector<protocol::string>{
"tsl.noop",
"tsl.loadWorkspace",
"tsl.indexFiles",
};
return options;
}
protocol::SemanticTokensOptions Initialize::BuildSemanticTokenOptions()
{
protocol::SemanticTokensOptions options;
options.legend.tokenTypes.emplace_back(protocol::SemanticTokenTypesLiterals::Namespace);
options.legend.tokenTypes.emplace_back(protocol::SemanticTokenTypesLiterals::Class);
options.legend.tokenTypes.emplace_back(protocol::SemanticTokenTypesLiterals::Method);
options.legend.tokenTypes.emplace_back(protocol::SemanticTokenTypesLiterals::Function);
options.legend.tokenTypes.emplace_back(protocol::SemanticTokenTypesLiterals::Property);
options.legend.tokenTypes.emplace_back(protocol::SemanticTokenTypesLiterals::Variable);
options.legend.tokenTypes.emplace_back(protocol::SemanticTokenTypesLiterals::Parameter);
options.legend.tokenTypes.emplace_back(protocol::SemanticTokenTypesLiterals::Keyword);
options.legend.tokenTypes.emplace_back(protocol::SemanticTokenTypesLiterals::Comment);
options.legend.tokenTypes.emplace_back(protocol::SemanticTokenTypesLiterals::String);
options.legend.tokenTypes.emplace_back(protocol::SemanticTokenTypesLiterals::Number);
options.legend.tokenTypes.emplace_back(protocol::SemanticTokenTypesLiterals::Operator);
options.legend.tokenModifiers.emplace_back(protocol::SemanticTokenModifiersLiterals::Declaration);
options.legend.tokenModifiers.emplace_back(protocol::SemanticTokenModifiersLiterals::Definition);
options.legend.tokenModifiers.emplace_back(protocol::SemanticTokenModifiersLiterals::Static);
options.legend.tokenModifiers.emplace_back(protocol::SemanticTokenModifiersLiterals::Readonly);
options.legend.tokenModifiers.emplace_back(protocol::SemanticTokenModifiersLiterals::Modification);
options.range = true;
options.full = protocol::SemanticTokensOptions::Full{ .delta = true };
return options;
}
void Initialize::ProcessWorkspaceFolder(const std::vector<protocol::WorkspaceFolder>& workspace_folders, ExecutionContext& context)
{
auto& manager_hub = context.GetManagerHub();
@@ -219,24 +95,24 @@ namespace lsp::provider
for (const auto& workspace_folder : workspace_folders)
{
auto task_id = std::format("Load workspace symbols: {}", workspace_folder.uri);
scheduler.Submit(task_id, [&manager_hub, uri = workspace_folder.uri, folder_name = workspace_folder.name]() -> std::optional<std::string> {
scheduler.Submit(task_id, [&manager_hub, uri = workspace_folder.uri, folder_name = workspace_folder.name](std::stop_token stop_token) -> std::optional<std::string> {
try
{
manager_hub.symbols().LoadWorkspace(uri);
manager_hub.symbols().LoadWorkspace(uri, stop_token);
return std::format("Loaded workspace {} symbols", uri);
}
catch (const std::exception& e)
{
spdlog::error("Failed to load workspace {} symbols: {}", folder_name, e.what());
throw; // Request会处理异常
} }, [](const std::optional<std::string>& result, bool cancelled) {
if (cancelled)
} }, [](const ::lsp::scheduler::async_executor::TaskResult& result) {
if (result.status == ::lsp::scheduler::async_executor::TaskStatus::kCancelled)
{
spdlog::debug("Workspace loading task cancelled");
}
else if (result)
else if (result.status == ::lsp::scheduler::async_executor::TaskStatus::kCompleted && result.value)
{
spdlog::info("Workspace loading result: {}", *result);
spdlog::info("Workspace loading result: {}", *result.value);
} });
}
+1 -159
View File
@@ -8,7 +8,6 @@ import lsp.provider.base.registry;
import lsp.provider.completion_item.resolve;
import lsp.provider.initialize.initialize;
import lsp.provider.initialized.initialized;
import lsp.provider.shutdown.shutdown;
import lsp.provider.text_document.definition;
import lsp.provider.text_document.did_change;
import lsp.provider.text_document.did_close;
@@ -16,175 +15,18 @@ import lsp.provider.text_document.did_open;
import lsp.provider.text_document.completion;
import lsp.provider.trace.set_trace;
// NOTE: keep other providers implemented but disabled.
// Uncomment when re-enabling additional capabilities.
// import lsp.provider.call_hierarchy.incoming_calls;
// import lsp.provider.call_hierarchy.outgoing_calls;
// import lsp.provider.cancel_request.cancel_request;
// import lsp.provider.client.register_capability;
// import lsp.provider.client.unregister_capability;
// import lsp.provider.code_action.resolve;
// import lsp.provider.code_lens.resolve;
// import lsp.provider.document_link.resolve;
// import lsp.provider.exit.exit;
// import lsp.provider.inlay_hint.resolve;
// import lsp.provider.telemetry.event;
// import lsp.provider.text_document.code_action;
// import lsp.provider.text_document.code_lens;
// import lsp.provider.text_document.color_presentation;
// import lsp.provider.text_document.diagnostic;
// import lsp.provider.text_document.document_color;
// import lsp.provider.text_document.document_highlight;
// import lsp.provider.text_document.document_link;
// import lsp.provider.text_document.document_symbol;
// import lsp.provider.text_document.folding_range;
// import lsp.provider.text_document.formatting;
// import lsp.provider.text_document.hover;
// import lsp.provider.text_document.implementation;
// import lsp.provider.text_document.inlay_hint;
// import lsp.provider.text_document.inline_value;
// import lsp.provider.text_document.linked_editing_range;
// import lsp.provider.text_document.moniker;
// import lsp.provider.text_document.on_type_formatting;
// import lsp.provider.text_document.prepare_call_hierarchy;
// import lsp.provider.text_document.prepare_rename;
// import lsp.provider.text_document.prepare_type_hierarchy;
// import lsp.provider.text_document.publish_diagnostics;
// import lsp.provider.text_document.range_formatting;
// import lsp.provider.text_document.references;
// import lsp.provider.text_document.rename;
// import lsp.provider.text_document.selection_range;
// import lsp.provider.text_document.semantic_tokens;
// import lsp.provider.text_document.signature_help;
// import lsp.provider.text_document.type_definition;
// import lsp.provider.type_hierarchy.subtypes;
// import lsp.provider.type_hierarchy.supertypes;
// import lsp.provider.window.log_message;
// import lsp.provider.window.show_document;
// import lsp.provider.window.show_message;
// import lsp.provider.window.show_message_request;
// import lsp.provider.window.work_done_progress_create;
// import lsp.provider.workspace.apply_edit;
// import lsp.provider.workspace.code_lens_refresh;
// import lsp.provider.workspace.configuration;
// import lsp.provider.workspace.diagnostic;
// import lsp.provider.workspace.did_change_configuration;
// import lsp.provider.workspace.did_change_watched_files;
// import lsp.provider.workspace.did_change_workspace_folders;
// import lsp.provider.workspace.did_create_files;
// import lsp.provider.workspace.did_delete_files;
// import lsp.provider.workspace.did_rename_files;
// import lsp.provider.workspace.diagnostic_refresh;
// import lsp.provider.workspace.execute_command;
// import lsp.provider.workspace.inlay_hint_refresh;
// import lsp.provider.workspace.inline_value_refresh;
// import lsp.provider.workspace.semantic_tokens_refresh;
// import lsp.provider.workspace.symbol;
// import lsp.provider.workspace.workspace_folders;
// import lsp.provider.workspace.will_create_files;
// import lsp.provider.workspace.will_delete_files;
// import lsp.provider.workspace.will_rename_files;
// import lsp.provider.workspace_symbol.resolve;
export namespace lsp::provider
{
inline constexpr bool kEnableDiagnosticsPublisher = false;
using AllProviders = ProviderRegistry<
completion_item::Resolve,
Initialize,
Initialized,
Shutdown,
text_document::Completion,
text_document::Definition,
text_document::DidChange,
text_document::DidClose,
text_document::DidOpen,
SetTrace
>;
// Full provider registry (kept commented for quick re-enable during staged rollout):
// using AllProviders = ProviderRegistry<
// call_hierarchy::IncomingCalls,
// call_hierarchy::OutgoingCalls,
// CancelRequest,
// client::RegisterCapability,
// client::UnregisterCapability,
// code_action::Resolve,
// code_lens::Resolve,
// completion_item::Resolve,
// document_link::Resolve,
// Exit,
// Initialize,
// Initialized,
// inlay_hint::Resolve,
// Shutdown,
// telemetry::Event,
// text_document::CodeAction,
// text_document::CodeLens,
// text_document::ColorPresentation,
// text_document::Completion,
// text_document::Definition,
// text_document::Diagnostic,
// text_document::DidChange,
// text_document::DidClose,
// text_document::DidOpen,
// text_document::DocumentColor,
// text_document::DocumentHighlight,
// text_document::DocumentLink,
// text_document::DocumentSymbol,
// text_document::FoldingRange,
// text_document::Formatting,
// text_document::Hover,
// text_document::Implementation,
// text_document::InlayHint,
// text_document::InlineValue,
// text_document::LinkedEditingRange,
// text_document::Moniker,
// text_document::OnTypeFormatting,
// text_document::PrepareCallHierarchy,
// text_document::PrepareRename,
// text_document::PrepareTypeHierarchy,
// text_document::PublishDiagnostics,
// text_document::RangeFormatting,
// text_document::References,
// text_document::Rename,
// text_document::SelectionRange,
// text_document::SemanticTokensRange,
// text_document::SemanticTokensFull,
// text_document::SemanticTokensFullDelta,
// text_document::SignatureHelp,
// text_document::TypeDefinition,
// SetTrace,
// type_hierarchy::Subtypes,
// type_hierarchy::Supertypes,
// window::LogMessage,
// window::ShowDocument,
// window::ShowMessage,
// window::ShowMessageRequest,
// window::WorkDoneProgressCreate,
// workspace::ApplyEdit,
// workspace::CodeLensRefresh,
// workspace::Configuration,
// workspace::Diagnostic,
// workspace::DidChangeConfiguration,
// workspace::DidChangeWatchedFiles,
// workspace::DidChangeWorkspaceFolders,
// workspace::DidCreateFiles,
// workspace::DidDeleteFiles,
// workspace::DidRenameFiles,
// workspace::DiagnosticRefresh,
// workspace::ExecuteCommand,
// workspace::InlayHintRefresh,
// workspace::InlineValueRefresh,
// workspace::SemanticTokensRefresh,
// workspace::Symbol,
// workspace::WorkspaceFolders,
// workspace::WillCreateFiles,
// workspace::WillDeleteFiles,
// workspace::WillRenameFiles,
// workspace_symbol::Resolve
// >;
SetTrace>;
static_assert(AllProviders::kProviderCount > 0, "No providers registered.");
@@ -1,53 +0,0 @@
module;
export module lsp.provider.shutdown.shutdown;
import spdlog;
import std;
import lsp.protocol;
import lsp.codec.facade;
import lsp.provider.base.interface;
namespace transform = lsp::codec;
export namespace lsp::provider
{
class Shutdown : public AutoRegisterProvider<Shutdown, IRequestProvider>
{
public:
static constexpr std::string_view kMethod = "shutdown";
static constexpr std::string_view kProviderName = "Shutdown";
Shutdown() = default;
std::string ProvideResponse(const protocol::RequestMessage& request, ExecutionContext& context) override;
};
}
namespace lsp::provider
{
std::string Shutdown::ProvideResponse(const protocol::RequestMessage& request, ExecutionContext& context)
{
spdlog::debug("ShutdownProvider: Providing response for method {}", request.method);
// 触发关闭事件
context.TriggerLifecycleEvent(ServerLifecycleEvent::kShuttingDown);
context.GetManagerHub().Shutdown();
// 构建响应 - shutdown 返回 null
protocol::ResponseMessage response;
response.id = request.id;
auto json = transform::Serialize(response);
if (!json.has_value())
return BuildErrorResponseMessage(request, protocol::ErrorCodes::InternalError, "Failed to serialize response");
spdlog::info("Shutdown request processed successfully");
return json.value();
}
}
@@ -1,6 +1,5 @@
module;
export module lsp.provider.workspace.did_change_workspace_folders;
import spdlog;
@@ -9,6 +8,7 @@ import std;
import lsp.protocol;
import lsp.codec.facade;
import lsp.provider.base.interface;
import lsp.scheduler.async_executor;
export namespace lsp::provider::workspace
{
@@ -123,10 +123,17 @@ namespace lsp::provider::workspace
return folders;
}
std::vector<protocol::DocumentUri> EnumerateWorkspaceFiles(const protocol::DocumentUri& workspace_uri)
std::vector<protocol::DocumentUri> EnumerateWorkspaceFiles(
const protocol::DocumentUri& workspace_uri,
std::stop_token stop_token)
{
std::vector<protocol::DocumentUri> uris;
if (stop_token.stop_requested())
{
return uris;
}
std::filesystem::path workspace_path;
try
{
@@ -147,6 +154,11 @@ namespace lsp::provider::workspace
for (const auto& entry : std::filesystem::recursive_directory_iterator(workspace_path, options))
{
if (stop_token.stop_requested())
{
return {};
}
if (!entry.is_regular_file())
{
continue;
@@ -211,22 +223,26 @@ namespace lsp::provider::workspace
auto task_id = std::format("Remove workspace folder: {}", folder.uri);
scheduler.Submit(
task_id,
[&hub, uri = folder.uri]() -> std::optional<std::string> {
auto uris = EnumerateWorkspaceFiles(uri);
[&hub, uri = folder.uri](std::stop_token stop_token) -> std::optional<std::string> {
auto uris = EnumerateWorkspaceFiles(uri, stop_token);
if (stop_token.stop_requested())
{
return std::nullopt;
}
if (!uris.empty())
{
hub.symbols().RemoveWorkspaceFiles(uris);
hub.symbols().RemoveWorkspaceFiles(uris, stop_token);
}
return std::format("Removed {} workspace file(s)", uris.size());
},
[](const std::optional<std::string>& result, bool cancelled) {
if (cancelled)
[](const ::lsp::scheduler::async_executor::TaskResult& result) {
if (result.status == ::lsp::scheduler::async_executor::TaskStatus::kCancelled)
{
spdlog::debug("Workspace folder removal task cancelled");
}
else if (result)
else if (result.status == ::lsp::scheduler::async_executor::TaskStatus::kCompleted && result.value)
{
spdlog::info("{}", *result);
spdlog::info("{}", *result.value);
}
});
}
@@ -236,22 +252,26 @@ namespace lsp::provider::workspace
auto task_id = std::format("Index workspace folder: {}", folder.uri);
scheduler.Submit(
task_id,
[&hub, uri = folder.uri]() -> std::optional<std::string> {
auto uris = EnumerateWorkspaceFiles(uri);
[&hub, uri = folder.uri](std::stop_token stop_token) -> std::optional<std::string> {
auto uris = EnumerateWorkspaceFiles(uri, stop_token);
if (stop_token.stop_requested())
{
return std::nullopt;
}
if (!uris.empty())
{
hub.symbols().IndexWorkspaceFiles(uris);
hub.symbols().IndexWorkspaceFiles(uris, stop_token);
}
return std::format("Indexed {} workspace file(s)", uris.size());
},
[](const std::optional<std::string>& result, bool cancelled) {
if (cancelled)
[](const ::lsp::scheduler::async_executor::TaskResult& result) {
if (result.status == ::lsp::scheduler::async_executor::TaskStatus::kCancelled)
{
spdlog::debug("Workspace folder indexing task cancelled");
}
else if (result)
else if (result.status == ::lsp::scheduler::async_executor::TaskStatus::kCompleted && result.value)
{
spdlog::info("{}", *result);
spdlog::info("{}", *result.value);
}
});
}
@@ -1,6 +1,5 @@
module;
export module lsp.provider.workspace.execute_command;
import spdlog;
@@ -107,8 +106,8 @@ namespace lsp::provider::workspace
auto& hub = context.GetManagerHub();
auto& scheduler = context.GetScheduler();
auto task_id = std::format("ExecuteCommand load workspace: {}", *uri);
scheduler.Submit(task_id, [&hub, uri = *uri]() -> std::optional<std::string> {
hub.symbols().LoadWorkspace(uri);
scheduler.Submit(task_id, [&hub, uri = *uri](std::stop_token stop_token) -> std::optional<std::string> {
hub.symbols().LoadWorkspace(uri, stop_token);
return std::string("ok");
});
@@ -125,8 +124,8 @@ namespace lsp::provider::workspace
auto& hub = context.GetManagerHub();
auto& scheduler = context.GetScheduler();
auto task_id = std::format("ExecuteCommand index files: {}", uris.size());
scheduler.Submit(task_id, [&hub, uris = std::move(uris)]() -> std::optional<std::string> {
hub.symbols().IndexWorkspaceFiles(uris);
scheduler.Submit(task_id, [&hub, uris = std::move(uris)](std::stop_token stop_token) -> std::optional<std::string> {
hub.symbols().IndexWorkspaceFiles(uris, stop_token);
return std::string("ok");
});
+231 -261
View File
@@ -4,70 +4,61 @@ import spdlog;
import taskflow;
import std;
export namespace lsp::scheduler
export namespace lsp::scheduler::async_executor
{
class AsyncExecutor;
namespace detail
enum class TaskStatus
{
struct ExecutionState
{
std::atomic<bool> cancelled{ false };
std::mutex mutex;
std::condition_variable cv;
bool completed = false;
bool callback_completed = false;
std::optional<std::string> result;
std::exception_ptr error;
std::chrono::steady_clock::time_point start_time{};
};
kCompleted,
kCancelled,
kFailed,
};
struct ActiveEntry
{
std::shared_ptr<ExecutionState> state;
std::function<void(const std::optional<std::string>& result, bool cancelled)> callback;
std::chrono::steady_clock::time_point start_time;
};
struct TaskResult
{
TaskStatus status;
std::optional<std::string> value;
std::exception_ptr error;
};
struct ExecutorMetrics
{
std::size_t running = 0;
std::uint64_t submitted = 0;
std::uint64_t completed = 0;
std::uint64_t failed = 0;
std::uint64_t cancelled = 0;
};
}
struct ExecutorMetrics
{
std::size_t running = 0;
std::uint64_t submitted = 0;
std::uint64_t completed = 0;
std::uint64_t failed = 0;
std::uint64_t cancelled = 0;
};
class AsyncExecutor;
class TaskHandle
{
public:
TaskHandle() = default;
TaskHandle(AsyncExecutor* executor, std::string task_id, std::shared_ptr<detail::ExecutionState> state) :
executor_(executor), task_id_(std::move(task_id)), state_(std::move(state))
{
}
bool Valid() const
{
return executor_ != nullptr && !task_id_.empty() && !state_.expired();
}
bool Valid() const;
bool Cancel() const;
bool Wait() const;
std::optional<std::string> GetResult() const;
std::optional<TaskResult> Wait() const;
std::optional<TaskResult> TryGetResult() const;
private:
AsyncExecutor* executor_ = nullptr;
std::string task_id_;
std::weak_ptr<detail::ExecutionState> state_;
struct State;
explicit TaskHandle(std::shared_ptr<State> state);
std::shared_ptr<State> state_;
friend class AsyncExecutor;
};
class AsyncExecutor
{
public:
using TaskClosure = std::function<std::optional<std::string>()>;
using TaskCallback = std::function<void(const std::optional<std::string>&, bool)>;
using TaskClosure = std::function<std::optional<std::string>(std::stop_token)>;
// A callback must not wait on its own handle or call WaitAll() on this
// executor because either operation would wait for the callback itself.
using TaskCallback = std::function<void(const TaskResult&)>;
explicit AsyncExecutor(std::size_t concurrency = std::thread::hardware_concurrency());
~AsyncExecutor();
@@ -77,89 +68,114 @@ export namespace lsp::scheduler
TaskHandle Submit(const std::string& task_id, TaskClosure task, TaskCallback callback = nullptr);
bool Cancel(const std::string& task_id);
bool WaitForTask(const std::string& task_id);
void WaitAll();
std::size_t GetRunningTaskCount() const;
void LogStatus() const;
detail::ExecutorMetrics GetStatistics() const;
ExecutorMetrics GetStatistics() const;
private:
void ExecuteTask(const std::string& task_id, TaskClosure task, std::shared_ptr<detail::ExecutionState> state, TaskCallback callback);
void CompleteTask(const std::string& task_id,
std::chrono::steady_clock::time_point start_time,
std::shared_ptr<detail::ExecutionState> state,
const std::optional<std::string>& result,
bool cancelled,
bool failed,
TaskCallback callback);
bool RegisterTask(const std::string& task_id, const detail::ActiveEntry& ctx);
void UnregisterTask(const std::string& task_id, const std::shared_ptr<detail::ExecutionState>& state);
bool MarkTaskCancelled(const std::string& task_id);
std::chrono::milliseconds GetElapsedTime(std::chrono::steady_clock::time_point start_time) const;
using TaskState = TaskHandle::State;
static bool RequestCancel(const std::shared_ptr<TaskState>& state);
void ExecuteTask(std::shared_ptr<TaskState> state, TaskClosure task, TaskCallback callback);
void CompleteTask(const std::shared_ptr<TaskState>& state, TaskResult result, const TaskCallback& callback);
void FinalizeTask(const std::shared_ptr<TaskState>& state);
private:
tf::Executor executor_;
mutable std::mutex mutex_;
std::unordered_map<std::string, detail::ActiveEntry> running_tasks_;
std::condition_variable active_tasks_cv_;
std::unordered_map<std::string, std::shared_ptr<TaskState>> current_tasks_;
std::unordered_set<std::shared_ptr<TaskState>> active_tasks_;
std::atomic<std::uint64_t> submitted_{ 0 };
std::atomic<std::uint64_t> completed_{ 0 };
std::atomic<std::uint64_t> failed_{ 0 };
std::atomic<std::uint64_t> cancelled_{ 0 };
static constexpr std::size_t kStatusLogInterval = 10;
friend class TaskHandle;
};
}
namespace lsp::scheduler
namespace lsp::scheduler::async_executor
{
struct TaskHandle::State
{
enum class Phase
{
kPending,
kRunning,
kCompleted,
};
explicit State(std::string id) : task_id(std::move(id)) {}
std::string task_id;
std::chrono::steady_clock::time_point start_time = std::chrono::steady_clock::now();
std::stop_source stop_source;
mutable std::mutex mutex;
std::condition_variable cv;
Phase phase = Phase::kPending;
bool cancel_requested = false;
std::optional<TaskResult> result;
bool callback_completed = false;
};
namespace
{
constexpr const char* kLogTag = "AsyncExecutor";
std::size_t NormalizeConcurrency(std::size_t concurrency)
{
return std::max<std::size_t>(1, concurrency);
}
template<typename Duration>
std::string FormatDuration(Duration duration)
{
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(duration).count();
if (ms < 1000)
return std::to_string(ms) + "ms";
else
return std::to_string(ms / 1000.0) + "s";
auto milliseconds = std::chrono::duration_cast<std::chrono::milliseconds>(duration).count();
if (milliseconds < 1000)
return std::to_string(milliseconds) + "ms";
return std::to_string(milliseconds / 1000.0) + "s";
}
}
TaskHandle::TaskHandle(std::shared_ptr<State> state) : state_(std::move(state)) {}
bool TaskHandle::Valid() const
{
return state_ != nullptr;
}
bool TaskHandle::Cancel() const
{
if (!Valid())
return false;
return executor_->Cancel(task_id_);
return AsyncExecutor::RequestCancel(state_);
}
bool TaskHandle::Wait() const
std::optional<TaskResult> TaskHandle::Wait() const
{
auto state = state_.lock();
if (!state)
return false;
std::unique_lock<std::mutex> lk(state->mutex);
state->cv.wait(lk, [state]() { return state->completed && state->callback_completed; });
return true;
}
std::optional<std::string> TaskHandle::GetResult() const
{
auto state = state_.lock();
if (!state)
if (!state_)
return std::nullopt;
std::unique_lock<std::mutex> lk(state->mutex);
if (!state->completed || !state->callback_completed || state->error)
return std::nullopt;
return state->result;
std::unique_lock lock(state_->mutex);
state_->cv.wait(lock, [this] { return state_->callback_completed; });
return state_->result;
}
AsyncExecutor::AsyncExecutor(std::size_t concurrency) : executor_(concurrency)
std::optional<TaskResult> TaskHandle::TryGetResult() const
{
spdlog::info("{}: Initialized with {} threads", kLogTag, concurrency);
if (!state_)
return std::nullopt;
std::lock_guard lock(state_->mutex);
if (!state_->callback_completed)
return std::nullopt;
return state_->result;
}
AsyncExecutor::AsyncExecutor(std::size_t concurrency) : executor_(NormalizeConcurrency(concurrency))
{
spdlog::info("{}: Initialized with {} threads", kLogTag, NormalizeConcurrency(concurrency));
}
AsyncExecutor::~AsyncExecutor()
@@ -171,91 +187,71 @@ namespace lsp::scheduler
TaskHandle AsyncExecutor::Submit(const std::string& task_id, TaskClosure task, TaskCallback callback)
{
detail::ActiveEntry entry;
entry.state = std::make_shared<detail::ExecutionState>();
entry.state->start_time = std::chrono::steady_clock::now();
entry.callback = callback;
entry.start_time = entry.state->start_time;
auto state = std::make_shared<TaskState>(task_id);
std::shared_ptr<TaskState> replaced_state;
{
std::lock_guard lock(mutex_);
auto current = current_tasks_.find(task_id);
if (current != current_tasks_.end())
replaced_state = current->second;
current_tasks_[task_id] = state;
active_tasks_.insert(state);
++submitted_;
}
bool replaced_existing = RegisterTask(task_id, entry);
if (replaced_existing)
if (replaced_state)
{
RequestCancel(replaced_state);
spdlog::warn("[{}] Replaced existing task", task_id);
}
try
{
executor_.async([this, task_id, task = std::move(task), state = entry.state, callback]() mutable {
ExecuteTask(task_id, std::move(task), state, callback);
executor_.async([this, state, task = std::move(task), callback]() mutable {
ExecuteTask(std::move(state), std::move(task), std::move(callback));
});
}
catch (const std::exception& e)
catch (...)
{
spdlog::error("[{}] Failed to submit task: {}", task_id, e.what());
CompleteTask(task_id, entry.start_time, entry.state, std::nullopt, false, true, callback);
auto error = std::current_exception();
spdlog::error("[{}] Failed to submit task", task_id);
CompleteTask(state, TaskResult{ TaskStatus::kFailed, std::nullopt, error }, callback);
}
return TaskHandle(this, task_id, entry.state);
return TaskHandle(std::move(state));
}
bool AsyncExecutor::Cancel(const std::string& task_id)
{
return MarkTaskCancelled(task_id);
}
bool AsyncExecutor::WaitForTask(const std::string& task_id)
{
std::shared_ptr<detail::ExecutionState> state;
std::shared_ptr<TaskState> state;
{
std::unique_lock<std::mutex> lock(mutex_);
auto it = running_tasks_.find(task_id);
if (it == running_tasks_.end())
std::lock_guard lock(mutex_);
auto current = current_tasks_.find(task_id);
if (current == current_tasks_.end())
return false;
state = it->second.state;
state = current->second;
}
std::unique_lock<std::mutex> lk(state->mutex);
state->cv.wait(lk, [state]() { return state->completed && state->callback_completed; });
return true;
return RequestCancel(state);
}
void AsyncExecutor::WaitAll()
{
std::vector<std::shared_ptr<detail::ExecutionState>> tasks;
{
std::unique_lock<std::mutex> lock(mutex_);
for (const auto& [_, entry] : running_tasks_)
{
tasks.push_back(entry.state);
}
std::unique_lock lock(mutex_);
active_tasks_cv_.wait(lock, [this] { return active_tasks_.empty(); });
}
for (const auto& state : tasks)
{
std::unique_lock<std::mutex> lk(state->mutex);
state->cv.wait(lk, [state]() { return state->completed && state->callback_completed; });
}
// Ensure the underlying executor finishes any tasks that may have been
// cancelled or replaced but are still running.
executor_.wait_for_all();
}
std::size_t AsyncExecutor::GetRunningTaskCount() const
{
std::unique_lock<std::mutex> lock(mutex_);
return running_tasks_.size();
std::lock_guard lock(mutex_);
return active_tasks_.size();
}
void AsyncExecutor::LogStatus() const
{
std::unique_lock<std::mutex> lock(mutex_);
detail::ExecutorMetrics metrics;
metrics.running = running_tasks_.size();
metrics.submitted = submitted_;
metrics.completed = completed_;
metrics.failed = failed_;
metrics.cancelled = cancelled_;
const auto metrics = GetStatistics();
spdlog::info("[{}] Running: {}, Submitted: {}, Completed: {}, Failed: {}, Cancelled: {}",
kLogTag,
metrics.running,
@@ -265,157 +261,131 @@ namespace lsp::scheduler
metrics.cancelled);
}
detail::ExecutorMetrics AsyncExecutor::GetStatistics() const
ExecutorMetrics AsyncExecutor::GetStatistics() const
{
detail::ExecutorMetrics metrics;
std::unique_lock<std::mutex> lock(mutex_);
metrics.running = running_tasks_.size();
metrics.submitted = submitted_;
metrics.completed = completed_;
metrics.failed = failed_;
metrics.cancelled = cancelled_;
ExecutorMetrics metrics;
{
std::lock_guard lock(mutex_);
metrics.running = active_tasks_.size();
}
metrics.submitted = submitted_.load();
metrics.completed = completed_.load();
metrics.failed = failed_.load();
metrics.cancelled = cancelled_.load();
return metrics;
}
void AsyncExecutor::ExecuteTask(const std::string& task_id, TaskClosure task, std::shared_ptr<detail::ExecutionState> state, TaskCallback callback)
bool AsyncExecutor::RequestCancel(const std::shared_ptr<TaskState>& state)
{
if (!state)
return false;
{
std::lock_guard lock(state->mutex);
if (state->phase == TaskState::Phase::kCompleted || state->cancel_requested)
return false;
state->cancel_requested = true;
}
return state->stop_source.request_stop();
}
void AsyncExecutor::ExecuteTask(std::shared_ptr<TaskState> state, TaskClosure task, TaskCallback callback)
{
const auto stop_token = state->stop_source.get_token();
bool cancelled_before_start = false;
{
std::lock_guard lock(state->mutex);
cancelled_before_start = state->cancel_requested;
if (!cancelled_before_start)
state->phase = TaskState::Phase::kRunning;
}
if (cancelled_before_start)
{
CompleteTask(state, TaskResult{ TaskStatus::kCancelled, std::nullopt, nullptr }, callback);
return;
}
TaskResult result;
try
{
auto result = task();
CompleteTask(task_id,
state->start_time,
state,
result,
state->cancelled.load(std::memory_order_relaxed),
false,
callback);
auto value = task(stop_token);
if (stop_token.stop_requested())
result = TaskResult{ TaskStatus::kCancelled, std::nullopt, nullptr };
else
result = TaskResult{ TaskStatus::kCompleted, std::move(value), nullptr };
}
catch (const std::exception& e)
{
spdlog::error("[{}] Task threw exception: {}", task_id, e.what());
CompleteTask(task_id,
state->start_time,
state,
std::nullopt,
state->cancelled.load(std::memory_order_relaxed),
true,
callback);
spdlog::error("[{}] Task threw exception: {}", state->task_id, e.what());
result = TaskResult{ TaskStatus::kFailed, std::nullopt, std::current_exception() };
}
catch (...)
{
spdlog::error("[{}] Task threw unknown exception", task_id);
CompleteTask(task_id,
state->start_time,
state,
std::nullopt,
state->cancelled.load(std::memory_order_relaxed),
true,
callback);
spdlog::error("[{}] Task threw unknown exception", state->task_id);
result = TaskResult{ TaskStatus::kFailed, std::nullopt, std::current_exception() };
}
CompleteTask(state, std::move(result), callback);
}
void AsyncExecutor::CompleteTask(const std::string& task_id,
std::chrono::steady_clock::time_point start_time,
std::shared_ptr<detail::ExecutionState> state,
const std::optional<std::string>& result,
bool cancelled,
bool failed,
TaskCallback callback)
void AsyncExecutor::CompleteTask(const std::shared_ptr<TaskState>& state, TaskResult result, const TaskCallback& callback)
{
if (!state)
return;
const bool is_cancelled = cancelled || state->cancelled.load(std::memory_order_relaxed);
{
std::unique_lock<std::mutex> lk(state->mutex);
state->completed = true;
std::lock_guard lock(state->mutex);
if (result.status == TaskStatus::kCompleted && state->cancel_requested)
result = TaskResult{ TaskStatus::kCancelled, std::nullopt, nullptr };
state->phase = TaskState::Phase::kCompleted;
state->result = result;
state->cancelled = is_cancelled;
if (failed)
state->error = std::make_exception_ptr(std::runtime_error("Task failed"));
}
UnregisterTask(task_id, state);
auto elapsed = GetElapsedTime(start_time);
bool callback_failed = false;
if (callback)
{
try
{
callback(result, is_cancelled);
callback(result);
}
catch (...)
{
callback_failed = true;
std::unique_lock<std::mutex> lk(state->mutex);
state->error = std::current_exception();
spdlog::error("[{}] Task callback threw exception", state->task_id);
}
}
const bool has_failed = failed || callback_failed;
if (has_failed)
++failed_;
else if (is_cancelled)
++cancelled_;
else
++completed_;
switch (result.status)
{
std::unique_lock<std::mutex> lk(state->mutex);
case TaskStatus::kCompleted:
++completed_;
break;
case TaskStatus::kCancelled:
++cancelled_;
break;
case TaskStatus::kFailed:
++failed_;
break;
}
FinalizeTask(state);
const auto elapsed = std::chrono::steady_clock::now() - state->start_time;
spdlog::info("[{}] Task completed. status={}, elapsed={}",
state->task_id,
static_cast<int>(result.status),
FormatDuration(elapsed));
}
void AsyncExecutor::FinalizeTask(const std::shared_ptr<TaskState>& state)
{
{
std::lock_guard lock(mutex_);
active_tasks_.erase(state);
auto current = current_tasks_.find(state->task_id);
if (current != current_tasks_.end() && current->second == state)
current_tasks_.erase(current);
std::lock_guard state_lock(state->mutex);
state->callback_completed = true;
}
state->cv.notify_all();
if (callback_failed)
spdlog::error("[{}] Task callback threw exception", task_id);
spdlog::info("[{}] Task completed. cancelled={}, failed={}, elapsed={}", task_id, is_cancelled, has_failed, FormatDuration(elapsed));
active_tasks_cv_.notify_all();
}
bool AsyncExecutor::RegisterTask(const std::string& task_id, const detail::ActiveEntry& ctx)
{
std::unique_lock<std::mutex> lock(mutex_);
auto it = running_tasks_.find(task_id);
const bool replaced = it != running_tasks_.end();
if (it != running_tasks_.end())
{
// Mark prior task as cancelled; completion will observe this flag.
it->second.state->cancelled.store(true, std::memory_order_relaxed);
}
running_tasks_[task_id] = ctx;
++submitted_;
return replaced;
}
void AsyncExecutor::UnregisterTask(const std::string& task_id, const std::shared_ptr<detail::ExecutionState>& state)
{
std::unique_lock<std::mutex> lock(mutex_);
auto it = running_tasks_.find(task_id);
if (it != running_tasks_.end() && it->second.state == state)
{
running_tasks_.erase(it);
}
}
bool AsyncExecutor::MarkTaskCancelled(const std::string& task_id)
{
std::unique_lock<std::mutex> lock(mutex_);
auto it = running_tasks_.find(task_id);
if (it == running_tasks_.end())
return false;
auto& state = it->second.state;
{
std::unique_lock<std::mutex> lk(state->mutex);
state->cancelled = true;
}
return true;
}
std::chrono::milliseconds AsyncExecutor::GetElapsedTime(std::chrono::steady_clock::time_point start_time) const
{
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start_time);
}
}
+86 -96
View File
@@ -1,141 +1,131 @@
module;
export module lsp.utils.args_parser;
import spdlog;
import spdlog;
import std;
export namespace lsp::utils
{
struct ServerConfig
{
bool use_stderr = false;
bool show_help = false;
std::size_t thread_count = 4;
spdlog::level::level_enum log_level = spdlog::level::info;
std::string log_file;
std::string interpreter_path;
};
class ArgsParser
enum class ParseAction
{
public:
ArgsParser(const ArgsParser&) = delete;
ArgsParser& operator=(const ArgsParser&) = delete;
static ArgsParser& Instance();
const ServerConfig& Parse(int argc, char* argv[]);
const ServerConfig& GetConfig() const;
static void SetupLogger(const ServerConfig& config);
static void PrintHelp(const std::string& program_name);
private:
ArgsParser() = default;
~ArgsParser() = default;
ServerConfig config_;
kRun,
kShowHelp,
};
struct ParseResult
{
ParseAction action = ParseAction::kRun;
ServerConfig config;
};
std::expected<ParseResult, std::string> ParseArgs(int argc, char* const argv[]);
void PrintHelp(std::ostream& output, std::string_view program_name);
}
namespace lsp::utils
{
ArgsParser& ArgsParser::Instance()
namespace
{
static ArgsParser instance;
return instance;
constexpr std::size_t kMinThreadCount = 1;
constexpr std::size_t kMaxThreadCount = 256;
std::optional<spdlog::level::level_enum> ParseLogLevel(std::string_view value)
{
if (value == "trace")
return spdlog::level::trace;
if (value == "debug")
return spdlog::level::debug;
if (value == "info")
return spdlog::level::info;
if (value == "warn")
return spdlog::level::warn;
if (value == "error")
return spdlog::level::err;
if (value == "off")
return spdlog::level::off;
return std::nullopt;
}
}
const ServerConfig& ArgsParser::Parse(int argc, char* argv[])
std::expected<ParseResult, std::string> ParseArgs(int argc, char* const argv[])
{
config_ = ServerConfig{};
// Default to stderr so LSP stdio (stdout) stays clean.
config_.use_stderr = true;
for (int i = 1; i < argc; ++i)
{
if (std::string_view(argv[i]) == "--help")
return ParseResult{ .action = ParseAction::kShowHelp, .config = {} };
}
ParseResult result;
constexpr std::string_view kLogPrefix = "--log=";
constexpr std::string_view kLogFilePrefix = "--log-file=";
constexpr std::string_view kThreadsPrefix = "--threads=";
constexpr std::string_view kInterpreterPrefix = "--interpreter=";
for (int i = 1; i < argc; ++i)
{
std::string arg = argv[i];
if (arg == "--help")
const std::string_view argument = argv[i];
if (argument.starts_with(kLogPrefix))
{
config_.show_help = true;
return config_;
const auto value = argument.substr(kLogPrefix.size());
auto level = ParseLogLevel(value);
if (!level)
return std::unexpected("Invalid value for --log: '" +
std::string(value) + "'");
result.config.log_level = *level;
}
if (arg == "--log=trace")
config_.log_level = spdlog::level::trace;
else if (arg == "--log=debug")
config_.log_level = spdlog::level::debug;
else if (arg == "--log=info")
config_.log_level = spdlog::level::info;
else if (arg == "--log=warn")
config_.log_level = spdlog::level::warn;
else if (arg == "--log=error")
config_.log_level = spdlog::level::err;
else if (arg == "--log=off")
config_.log_level = spdlog::level::off;
else if (arg == "--log-stderr")
config_.use_stderr = true;
else if (arg == "--log-stdout")
config_.use_stderr = false;
else if (arg.starts_with(kLogFilePrefix))
config_.log_file = arg.substr(kLogFilePrefix.size());
else if (arg == "--use-stdio")
config_.use_stderr = true;
else if (arg.starts_with(kThreadsPrefix))
else if (argument.starts_with(kLogFilePrefix))
{
auto value = arg.substr(kThreadsPrefix.size());
config_.thread_count = std::max<std::size_t>(1, static_cast<std::size_t>(std::stoi(value)));
const auto value = argument.substr(kLogFilePrefix.size());
if (value.empty())
return std::unexpected("--log-file requires a non-empty path");
result.config.log_file = value;
}
else if (arg.starts_with(kInterpreterPrefix))
else if (argument.starts_with(kThreadsPrefix))
{
config_.interpreter_path = arg.substr(kInterpreterPrefix.size());
const auto value = argument.substr(kThreadsPrefix.size());
std::size_t count = 0;
const auto [end, error] =
std::from_chars(value.data(), value.data() + value.size(), count);
if (error != std::errc{} || end != value.data() + value.size() ||
count < kMinThreadCount || count > kMaxThreadCount)
{
return std::unexpected("Invalid value for --threads: '" +
std::string(value) + "' (expected 1..256)");
}
result.config.thread_count = count;
}
else if (argument.starts_with(kInterpreterPrefix))
{
const auto value = argument.substr(kInterpreterPrefix.size());
if (value.empty())
return std::unexpected("--interpreter requires a non-empty path");
result.config.interpreter_path = value;
}
else
{
return std::unexpected("Unknown argument: " + std::string(argument));
}
}
return config_;
return result;
}
const ServerConfig& ArgsParser::GetConfig() const
void PrintHelp(std::ostream& output, std::string_view program_name)
{
return config_;
}
void ArgsParser::SetupLogger(const ServerConfig& config)
{
spdlog::set_pattern("%Y-%m-%d %H:%M:%S.%e [%t] [%^%l%$] %v");
if (!config.log_file.empty())
{
auto file_logger = spdlog::basic_logger_mt("file_logger", config.log_file);
file_logger->set_level(config.log_level);
spdlog::set_default_logger(file_logger);
}
else
{
auto console_logger = config.use_stderr ? spdlog::stderr_logger_mt("console_logger") : spdlog::stdout_logger_mt("console_logger");
console_logger->set_level(config.log_level);
spdlog::set_default_logger(console_logger);
}
spdlog::set_level(config.log_level);
}
void ArgsParser::PrintHelp(const std::string& program_name)
{
std::cout << "Usage: " << program_name << " [options]\\n\\n"
<< "Options:\\n"
<< " --help Show this help message\\n"
<< " --log=<level> Set log level (trace, debug, info, warn, error, off)\\n"
<< " --log-stderr Output logs to stderr (default)\\n"
<< " --log-stdout Output logs to stdout\\n"
<< " --log-file=<path> Output logs to specified file\\n"
<< " --use-stdio Alias for --log-stderr (keep stdout clean for LSP)\\n"
<< " --threads=<count> Number of worker threads\\n"
<< " --interpreter=<path> Custom interpreter path\\n";
output << "Usage: " << program_name << " [options]\n\n"
<< "Options:\n"
<< " --help Show this help message\n"
<< " --log=<level> Set log level (trace, debug, info, warn, error, off)\n"
<< " --log-file=<path> Output logs to specified file\n"
<< " --threads=<count> Number of worker threads (1-256)\n"
<< " --interpreter=<path> Custom interpreter path\n";
}
}
+129 -34
View File
@@ -9,59 +9,154 @@ import lsp.protocol;
export namespace lsp::utils::text_coordinates
{
struct BytePosition
{
protocol::uinteger offset;
TSPoint point;
};
BytePosition ToBytePosition(const protocol::Position& position, const protocol::string& content);
protocol::Position ToPosition(TSPoint point, const protocol::string& content);
protocol::uinteger ToOffset(const protocol::Position& position, const protocol::string& content);
TSPoint ToPoint(const protocol::Position& position);
TSPoint CalculateEndPoint(const protocol::string& text, TSPoint start);
}
namespace lsp::utils::text_coordinates
{
protocol::uinteger ToOffset(const protocol::Position& position, const protocol::string& content)
namespace
{
protocol::uinteger offset = 0;
protocol::uinteger current_line = 0;
while (offset < content.length() && current_line < position.line)
struct DecodedCharacter
{
if (content[offset] == '\n')
current_line++;
offset++;
}
std::size_t byte_count;
protocol::uinteger utf16_units;
};
if (offset >= content.length())
return content.length();
protocol::uinteger current_char = 0;
while (offset < content.length() && current_char < position.character)
DecodedCharacter DecodeCharacter(std::string_view content, std::size_t offset)
{
if (content[offset] == '\n')
break;
const auto lead = static_cast<unsigned char>(content[offset]);
if ((lead & 0x80U) == 0)
return { 1, 1 };
unsigned char ch = static_cast<unsigned char>(content[offset]);
if ((ch & 0x80) == 0)
offset += 1;
else if ((ch & 0xE0) == 0xC0)
offset += 2;
else if ((ch & 0xF0) == 0xE0)
offset += 3;
else if ((ch & 0xF8) == 0xF0)
offset += 4;
std::size_t byte_count = 0;
std::uint32_t code_point = 0;
std::uint32_t minimum = 0;
if ((lead & 0xE0U) == 0xC0U)
{
byte_count = 2;
code_point = lead & 0x1FU;
minimum = 0x80U;
}
else if ((lead & 0xF0U) == 0xE0U)
{
byte_count = 3;
code_point = lead & 0x0FU;
minimum = 0x800U;
}
else if ((lead & 0xF8U) == 0xF0U)
{
byte_count = 4;
code_point = lead & 0x07U;
minimum = 0x10000U;
}
else
offset += 1;
current_char++;
}
{
return { 1, 1 };
}
return std::min(offset, static_cast<protocol::uinteger>(content.length()));
if (byte_count > content.size() - offset)
return { 1, 1 };
for (std::size_t index = 1; index < byte_count; ++index)
{
const auto continuation = static_cast<unsigned char>(content[offset + index]);
if ((continuation & 0xC0U) != 0x80U)
return { 1, 1 };
code_point = (code_point << 6U) | (continuation & 0x3FU);
}
if (code_point < minimum || code_point > 0x10FFFFU ||
(code_point >= 0xD800U && code_point <= 0xDFFFU))
{
return { 1, 1 };
}
return { byte_count, code_point >= 0x10000U ? 2U : 1U };
}
}
TSPoint ToPoint(const protocol::Position& position)
BytePosition ToBytePosition(const protocol::Position& position, const protocol::string& content)
{
return TSPoint{
.row = static_cast<uint32_t>(position.line),
.column = static_cast<uint32_t>(position.character)
std::size_t offset = 0;
std::size_t line_start = 0;
std::uint32_t row = 0;
while (offset < content.size() && row < position.line)
{
if (content[offset++] == '\n')
{
++row;
line_start = offset;
}
}
protocol::uinteger utf16_units = 0;
while (offset < content.size() && content[offset] != '\n' &&
utf16_units < position.character)
{
const auto decoded = DecodeCharacter(content, offset);
const auto remaining = position.character - utf16_units;
if (decoded.utf16_units > remaining)
break;
offset += decoded.byte_count;
utf16_units += decoded.utf16_units;
}
return {
.offset = static_cast<protocol::uinteger>(offset),
.point = TSPoint{
.row = row,
.column = static_cast<std::uint32_t>(offset - line_start),
},
};
}
protocol::Position ToPosition(TSPoint point, const protocol::string& content)
{
std::size_t offset = 0;
std::uint32_t row = 0;
while (offset < content.size() && row < point.row)
{
if (content[offset++] == '\n')
++row;
}
protocol::uinteger utf16_units = 0;
std::size_t byte_column = 0;
while (offset < content.size() && content[offset] != '\n' &&
byte_column < point.column)
{
const auto decoded = DecodeCharacter(content, offset);
if (decoded.byte_count > point.column - byte_column)
break;
offset += decoded.byte_count;
byte_column += decoded.byte_count;
utf16_units += decoded.utf16_units;
}
return {
.line = row,
.character = utf16_units,
};
}
protocol::uinteger ToOffset(const protocol::Position& position, const protocol::string& content)
{
return ToBytePosition(position, content).offset;
}
TSPoint CalculateEndPoint(const protocol::string& text, TSPoint start)
{
TSPoint end = start;
+13 -1
View File
@@ -28,6 +28,10 @@ if(EXISTS ${CMAKE_CURRENT_LIST_DIR}/test_scheduler/CMakeLists.txt)
add_subdirectory(test_scheduler)
endif()
if(EXISTS ${CMAKE_CURRENT_LIST_DIR}/test_args_parser/CMakeLists.txt)
add_subdirectory(test_args_parser)
endif()
if(EXISTS ${CMAKE_CURRENT_LIST_DIR}/test_semantic/CMakeLists.txt)
add_subdirectory(test_semantic)
endif()
@@ -48,7 +52,15 @@ if(BUILD_TESTS)
COMMAND ${PYTHON3_EXECUTABLE}
${CMAKE_CURRENT_LIST_DIR}/run_lsp_json_tests.py
--server $<TARGET_FILE:tsl-server>)
add_test(NAME test_cli_startup
COMMAND ${PYTHON3_EXECUTABLE}
${CMAKE_CURRENT_LIST_DIR}/test_cli_startup.py
--server $<TARGET_FILE:tsl-server>)
add_test(NAME test_core_server
COMMAND ${PYTHON3_EXECUTABLE}
${CMAKE_CURRENT_LIST_DIR}/test_core_server.py
--server $<TARGET_FILE:test_provider>)
else()
message(WARNING "python3 not found; skipping test_lsp_json registration")
message(WARNING "python3 not found; skipping test_lsp_json and test_cli_startup registration")
endif()
endif()
+1 -1
View File
@@ -185,7 +185,7 @@ def main() -> int:
messages = load_messages(request_dir, replacements, files)
proc = subprocess.Popen(
[str(server_path), "--log=off", "--log-stderr"],
[str(server_path), "--log=off"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
@@ -0,0 +1,40 @@
cmake_minimum_required(VERSION 4.0)
project(test_args_parser LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPERIMENTAL_CXX_MODULE_DYNDEP 1)
find_package(spdlog CONFIG REQUIRED)
find_package(fmt CONFIG REQUIRED)
add_executable(test_args_parser main.cc test_args_parser.cppm)
if(TARGET std_module)
add_dependencies(test_args_parser std_module)
endif()
target_sources(
test_args_parser
PRIVATE
FILE_SET cxx_modules TYPE CXX_MODULES
BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/../../src
FILES test_args_parser.cppm
../../src/bridge/spdlog.cppm
../../src/utils/args_parser.cppm)
target_compile_definitions(test_args_parser PRIVATE SPDLOG_HEADER_ONLY
FMT_HEADER_ONLY)
target_link_libraries(test_args_parser PRIVATE spdlog::spdlog_header_only
fmt::fmt-header-only)
target_compile_options(
test_args_parser
PRIVATE -Wall -Wextra -Wpedantic
-Wno-import-implementation-partition-unit-in-interface-unit
$<$<CONFIG:Debug>:-g -O0>
$<$<CONFIG:Release>:-O3>)
if(BUILD_TESTS)
add_test(NAME test_args_parser COMMAND $<TARGET_FILE:test_args_parser>)
endif()
+6
View File
@@ -0,0 +1,6 @@
import lsp.test.args_parser;
int main()
{
return Run();
}
@@ -0,0 +1,131 @@
module;
export module lsp.test.args_parser;
import spdlog;
import std;
import lsp.utils.args_parser;
namespace
{
void Expect(bool condition, std::string_view message)
{
if (!condition)
throw std::runtime_error(std::string(message));
}
auto Parse(std::initializer_list<std::string_view> arguments)
{
std::vector<std::string> storage{ "tsl-server" };
storage.reserve(arguments.size() + 1);
for (auto argument : arguments)
storage.emplace_back(argument);
std::vector<char*> argv;
argv.reserve(storage.size());
for (auto& argument : storage)
argv.push_back(argument.data());
return lsp::utils::ParseArgs(static_cast<int>(argv.size()), argv.data());
}
void ExpectError(std::initializer_list<std::string_view> arguments,
std::string_view fragment)
{
auto result = Parse(arguments);
Expect(!result.has_value(), "arguments should be rejected");
Expect(result.error().find(fragment) != std::string::npos,
"error should identify the invalid argument");
}
}
export int Run()
{
int failures = 0;
auto RunCase = [&failures](std::string_view name, auto test) {
try
{
test();
std::cout << "[PASS] " << name << '\n';
}
catch (const std::exception& error)
{
++failures;
std::cout << "[FAIL] " << name << ": " << error.what() << '\n';
}
};
RunCase("defaults", [] {
auto result = Parse({});
Expect(result.has_value(), "default arguments should parse");
Expect(result->action == lsp::utils::ParseAction::kRun,
"default action should run");
Expect(result->config.thread_count == 4, "default thread count should be 4");
Expect(result->config.log_level == spdlog::level::info,
"default log level should be info");
});
RunCase("valid values and last occurrence", [] {
auto result = Parse({ "--threads=8", "--threads=16", "--log=debug", "--log-file=server.log", "--interpreter=/opt/tsl" });
Expect(result.has_value(), "valid arguments should parse");
Expect(result->config.thread_count == 16, "last thread count should win");
Expect(result->config.log_level == spdlog::level::debug,
"debug log level should parse");
Expect(result->config.log_file == "server.log", "log file should parse");
Expect(result->config.interpreter_path == "/opt/tsl",
"interpreter path should parse");
});
RunCase("all log levels", [] {
const std::array levels{
std::pair{ "trace", spdlog::level::trace },
std::pair{ "debug", spdlog::level::debug },
std::pair{ "info", spdlog::level::info },
std::pair{ "warn", spdlog::level::warn },
std::pair{ "error", spdlog::level::err },
std::pair{ "off", spdlog::level::off },
};
for (const auto& [name, level] : levels)
{
const std::string argument = std::string("--log=") + name;
auto result = Parse({ argument });
Expect(result.has_value(), "known log level should parse");
Expect(result->config.log_level == level, "log level should match");
}
});
RunCase("help takes precedence", [] {
auto result = Parse({ "--unknown", "--help", "--threads=bad" });
Expect(result.has_value(), "help should bypass other validation");
Expect(result->action == lsp::utils::ParseAction::kShowHelp,
"help action should be returned");
});
RunCase("thread validation", [] {
Expect(Parse({ "--threads=1" }).has_value(), "one thread should parse");
Expect(Parse({ "--threads=256" }).has_value(), "256 threads should parse");
for (auto argument : { "--threads=", "--threads=0", "--threads=-1", "--threads=257", "--threads=12junk", "--threads=18446744073709551616" })
ExpectError({ argument }, "--threads");
});
RunCase("other invalid values", [] {
for (auto argument : { "--log=verbose", "--log-file=", "--interpreter=", "--log-stdout", "--log-stderr", "--use-stdio", "--unknown" })
ExpectError({ argument }, "--");
});
RunCase("help formatting", [] {
std::ostringstream output;
lsp::utils::PrintHelp(output, "tsl-server");
const auto text = output.str();
Expect(text.contains("Usage: tsl-server [options]\n\nOptions:\n"),
"help should contain physical line breaks");
Expect(!text.contains("\\n"), "help should not contain escaped newline text");
Expect(!text.contains("--log-stdout"), "removed stdout flag should be absent");
Expect(!text.contains("--log-stderr"), "removed stderr flag should be absent");
Expect(!text.contains("--use-stdio"), "removed stdio alias should be absent");
});
std::cout << "Failures: " << failures << '\n';
return failures == 0 ? 0 : 1;
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
import argparse
import subprocess
import tempfile
import unittest
from pathlib import Path
SERVER: Path
def run_server(*arguments: str) -> subprocess.CompletedProcess[bytes]:
return subprocess.run(
[str(SERVER), *arguments],
input=b"",
capture_output=True,
check=False,
timeout=5,
)
class CliStartupTest(unittest.TestCase):
def test_help_uses_real_newlines(self) -> None:
result = run_server("--help")
self.assertEqual(0, result.returncode)
self.assertIn(b"\n\nOptions:\n", result.stdout)
self.assertNotIn(b"\\n", result.stdout)
def test_invalid_threads_return_argument_error(self) -> None:
for argument in ("--threads=", "--threads=-1", "--threads=12junk"):
with self.subTest(argument=argument):
result = run_server(argument)
self.assertEqual(2, result.returncode)
self.assertIn(b"--threads", result.stderr)
self.assertNotIn(b"TSL-LSP server starting", result.stderr)
def test_removed_stdout_flag_is_rejected(self) -> None:
result = run_server("--log-stdout")
self.assertEqual(2, result.returncode)
self.assertEqual(b"", result.stdout)
def test_default_logger_never_writes_stdout(self) -> None:
result = run_server()
self.assertEqual(1, result.returncode)
self.assertEqual(b"", result.stdout)
self.assertIn(b"TSL-LSP server starting", result.stderr)
def test_log_file_failure_is_controlled(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
result = run_server(f"--log-file={temp_dir}")
self.assertEqual(1, result.returncode)
self.assertIn(b"Failed to initialize logger", result.stderr)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--server", type=Path, required=True)
args, unittest_args = parser.parse_known_args()
SERVER = args.server.resolve()
unittest.main(argv=[__file__, *unittest_args])
+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env python3
import argparse
import json
import os
import select
import subprocess
import time
from pathlib import Path
def frame(message: dict) -> bytes:
body = json.dumps(message, separators=(",", ":")).encode("utf-8")
return f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + body
def raw_frame(body: bytes) -> bytes:
return f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + body
def content_type_frame(message: dict) -> bytes:
body = json.dumps(message, separators=(",", ":")).encode("utf-8")
header = (
f"Content-Length: {len(body)}\r\n"
"Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n"
).encode("ascii")
return header + body
def read_messages(data: bytes) -> list[dict]:
messages = []
offset = 0
while offset < len(data):
header_end = data.find(b"\r\n\r\n", offset)
if header_end < 0:
raise RuntimeError("Incomplete LSP response header")
header = data[offset:header_end].decode("ascii")
fields = dict(line.split(": ", 1) for line in header.split("\r\n"))
length = int(fields["Content-Length"])
body_start = header_end + 4
body_end = body_start + length
if body_end > len(data):
raise RuntimeError("Incomplete LSP response body")
messages.append(json.loads(data[body_start:body_end].decode("utf-8")))
offset = body_end
return messages
def response_by_id(data: bytes, request_id: int | str) -> dict:
return next(message for message in read_messages(data)
if message.get("id") == request_id)
def run_raw(server: Path, payload: bytes) -> subprocess.CompletedProcess[bytes]:
return subprocess.run(
[str(server), "--core-server-fixture"],
input=payload,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=5,
check=False,
)
def run_batch(server: Path, messages: list[dict]) -> subprocess.CompletedProcess[bytes]:
return run_raw(server, b"".join(frame(message) for message in messages))
class LspClient:
def __init__(self, server: Path) -> None:
self.proc = subprocess.Popen(
[str(server), "--core-server-fixture"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.buffer = b""
def send(self, message: dict) -> None:
if self.proc.stdin is None:
raise RuntimeError("Fixture stdin is unavailable")
self.proc.stdin.write(frame(message))
self.proc.stdin.flush()
def read(self, timeout: float = 1.0) -> dict:
if self.proc.stdout is None:
raise RuntimeError("Fixture stdout is unavailable")
deadline = time.monotonic() + timeout
while True:
header_end = self.buffer.find(b"\r\n\r\n")
if header_end >= 0:
header = self.buffer[:header_end].decode("ascii")
fields = dict(line.split(": ", 1)
for line in header.split("\r\n"))
length = int(fields["Content-Length"])
body_start = header_end + 4
body_end = body_start + length
if len(self.buffer) >= body_end:
body = self.buffer[body_start:body_end]
self.buffer = self.buffer[body_end:]
return json.loads(body.decode("utf-8"))
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("Timed out waiting for LSP response")
ready, _, _ = select.select(
[self.proc.stdout.fileno()], [], [], remaining)
if not ready:
continue
chunk = os.read(self.proc.stdout.fileno(), 4096)
if not chunk:
raise RuntimeError("Fixture closed stdout before a response")
self.buffer += chunk
def close_input(self) -> int:
if self.proc.stdin is not None:
self.proc.stdin.close()
return self.proc.wait(timeout=5)
def kill(self) -> None:
if self.proc.poll() is None:
self.proc.kill()
self.proc.wait(timeout=5)
def assert_lifecycle(server: Path) -> None:
before_init = run_batch(server, [
{"jsonrpc": "2.0", "id": 1, "method": "shutdown"},
{"jsonrpc": "2.0", "method": "exit"},
])
if before_init.returncode != 1:
raise RuntimeError("shutdown before initialize should exit with code 1")
if response_by_id(before_init.stdout, 1)["error"]["code"] != -32002:
raise RuntimeError("shutdown before initialize should return ServerNotInitialized")
repeated = run_batch(server, [
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
{"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {}},
{"jsonrpc": "2.0", "id": 3, "method": "shutdown"},
{"jsonrpc": "2.0", "method": "exit"},
])
if repeated.returncode != 0:
raise RuntimeError("shutdown followed by exit should return code 0")
if response_by_id(repeated.stdout, 2)["error"]["code"] != -32600:
raise RuntimeError("repeated initialize should return InvalidRequest")
if response_by_id(repeated.stdout, 3).get("result", "missing") is not None:
raise RuntimeError("shutdown should return a null result")
direct_exit = run_batch(
server, [{"jsonrpc": "2.0", "method": "exit"}])
if direct_exit.returncode != 1:
raise RuntimeError("exit before shutdown should return code 1")
client = LspClient(server)
try:
client.send({
"jsonrpc": "2.0",
"id": 11,
"method": "initialize",
"params": {},
})
if client.read().get("id") != 11:
raise RuntimeError("missing initialize response")
client.send({"jsonrpc": "2.0", "id": 12, "method": "shutdown"})
if client.read().get("result", "missing") is not None:
raise RuntimeError("shutdown should return a null result")
time.sleep(0.1)
if client.proc.poll() is not None:
raise RuntimeError("server exited before receiving exit notification")
client.send({"jsonrpc": "2.0", "method": "exit"})
if client.close_input() != 0:
raise RuntimeError("orderly lifecycle should return code 0")
finally:
client.kill()
def assert_json_rpc_errors(server: Path) -> None:
payload = b"".join([
raw_frame(b'{"jsonrpc":"2.0",'),
raw_frame(b"[]"),
raw_frame(b'{"jsonrpc":"1.0","id":7,"method":"initialize"}'),
frame({
"jsonrpc": "2.0",
"id": 8,
"method": "initialize",
"params": {},
}),
frame({"jsonrpc": "2.0", "id": 10, "method": "shutdown"}),
frame({"jsonrpc": "2.0", "method": "exit"}),
])
result = run_raw(server, payload)
if result.returncode != 0:
raise RuntimeError("JSON-RPC error sequence should shut down cleanly")
responses = read_messages(result.stdout)
if responses[0].get("id", "missing") is not None:
raise RuntimeError(
f"ParseError response should use a null id: {responses[0]!r}")
if responses[0].get("error", {}).get("code") != -32700:
raise RuntimeError("Malformed JSON should return ParseError")
if responses[1].get("id", "missing") is not None:
raise RuntimeError("InvalidRequest response should use a null id")
if responses[1].get("error", {}).get("code") != -32600:
raise RuntimeError("A non-object JSON value should return InvalidRequest")
if response_by_id(result.stdout, 7).get("error", {}).get("code") != -32600:
raise RuntimeError("jsonrpc other than 2.0 should return InvalidRequest")
client = LspClient(server)
try:
client.send({
"jsonrpc": "2.0",
"id": 11,
"method": "initialize",
"params": {},
})
client.read()
client.send({"jsonrpc": "2.0", "id": 9, "method": "missing/method"})
if client.read().get("error", {}).get("code") != -32601:
raise RuntimeError("Unknown request method should return MethodNotFound")
client.send({"jsonrpc": "2.0", "id": 12, "method": "shutdown"})
client.read()
client.send({"jsonrpc": "2.0", "method": "exit"})
if client.close_input() != 0:
raise RuntimeError("unknown method sequence should shut down cleanly")
finally:
client.kill()
def assert_cancellation_and_failures(server: Path) -> None:
client = LspClient(server)
try:
client.send({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {},
})
if client.read().get("id") != 1:
raise RuntimeError("missing initialize response")
client.send({"jsonrpc": "2.0", "id": 2, "method": "test/block"})
client.send({"jsonrpc": "2.0", "id": "2", "method": "test/block"})
client.send({
"jsonrpc": "2.0",
"method": "$/cancelRequest",
"params": {"id": 2},
})
cancelled = client.read(timeout=1)
if cancelled.get("id") != 2 or \
cancelled.get("error", {}).get("code") != -32800:
raise RuntimeError("integer request id should be cancelled independently")
client.send({
"jsonrpc": "2.0",
"method": "$/cancelRequest",
"params": {"id": "2"},
})
string_cancelled = client.read(timeout=1)
if string_cancelled.get("id") != "2" or \
string_cancelled.get("error", {}).get("code") != -32800:
raise RuntimeError("string request id should be cancelled independently")
client.send({"jsonrpc": "2.0", "id": 3, "method": "test/throw"})
failed = client.read(timeout=1)
if failed.get("id") != 3 or \
failed.get("error", {}).get("code") != -32603:
raise RuntimeError("request exceptions should return InternalError")
client.send({"jsonrpc": "2.0", "id": 4, "method": "test/block"})
client.send({"jsonrpc": "2.0", "id": 4, "method": "test/block"})
duplicate = client.read(timeout=1)
if duplicate.get("id") != 4 or \
duplicate.get("error", {}).get("code") != -32600:
raise RuntimeError("duplicate active request id should return InvalidRequest")
client.send({
"jsonrpc": "2.0",
"method": "$/cancelRequest",
"params": {"id": 4},
})
cancelled_original = client.read(timeout=1)
if cancelled_original.get("id") != 4 or \
cancelled_original.get("error", {}).get("code") != -32800:
raise RuntimeError("duplicate id must not replace the original request")
client.send({"jsonrpc": "2.0", "method": "test/throwNotification"})
client.send({"jsonrpc": "2.0", "id": 5, "method": "shutdown"})
shutdown = client.read(timeout=1)
if shutdown.get("id") != 5 or shutdown.get("result", "missing") is not None:
raise RuntimeError("notification exception should not stop the server")
client.send({"jsonrpc": "2.0", "method": "exit"})
if client.close_input() != 0:
raise RuntimeError("fixture should exit cleanly after cancellation tests")
finally:
client.kill()
def assert_framing(server: Path) -> None:
invalid_payloads = [
b"Content-Length: 2junk\r\n\r\n{}",
b"Content-Length: 2\r\nContent-Length: 2\r\n\r\n{}",
b"Content-Length: 0\r\n\r\n",
b"Content-Length: 16777217\r\n\r\n",
b"Content-Length: 10\r\n\r\n{}",
b"Content-Length: 2\n\n{}",
b"X-Length: 2\r\n\r\n{}",
b"Content-Length: 2\r\nContent-Type: application/json\r\n\r\n{}",
]
for index, payload in enumerate(invalid_payloads):
result = run_raw(server, payload)
if result.returncode != 1:
raise RuntimeError(f"invalid framing case {index} should return code 1")
if result.stdout:
raise RuntimeError(f"invalid framing case {index} should not process its body")
valid_payload = b"".join([
content_type_frame({
"jsonrpc": "2.0",
"id": 31,
"method": "initialize",
"params": {},
}),
frame({"jsonrpc": "2.0", "id": 32, "method": "shutdown"}),
frame({"jsonrpc": "2.0", "method": "exit"}),
])
valid = run_raw(server, valid_payload)
if valid.returncode != 0:
raise RuntimeError("canonical Content-Type should be accepted")
if response_by_id(valid.stdout, 31).get("error"):
raise RuntimeError("initialize with canonical Content-Type should succeed")
if response_by_id(valid.stdout, 32).get("result", "missing") is not None:
raise RuntimeError("continuous framing should reach shutdown")
def assert_diagnostics(server: Path) -> None:
client = LspClient(server)
try:
client.send({
"jsonrpc": "2.0",
"id": 41,
"method": "initialize",
"params": {},
})
if client.read().get("id") != 41:
raise RuntimeError("missing initialize response")
uri = "file:///diagnostics.tsl"
client.send({
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": uri,
"languageId": "tsl",
"version": 1,
"text": "function broken(",
},
},
})
diagnostics = client.read(timeout=1)
if diagnostics.get("method") != "textDocument/publishDiagnostics":
raise RuntimeError("didOpen should publish diagnostics")
params = diagnostics.get("params", {})
if params.get("uri") != uri or not params.get("diagnostics"):
raise RuntimeError("syntax diagnostics should identify the opened document")
client.send({"jsonrpc": "2.0", "id": 42, "method": "shutdown"})
if client.read().get("id") != 42:
raise RuntimeError("missing shutdown response")
client.send({"jsonrpc": "2.0", "method": "exit"})
if client.close_input() != 0:
raise RuntimeError("diagnostics sequence should shut down cleanly")
finally:
client.kill()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--server", type=Path, required=True)
args = parser.parse_args()
assert_lifecycle(args.server)
assert_json_rpc_errors(args.server)
assert_cancellation_and_failures(args.server)
assert_framing(args.server)
assert_diagnostics(args.server)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+8 -3
View File
@@ -20,6 +20,7 @@ set(SOURCES
main.cc
test_main.cppm
../test_lsp_any/test_framework.cppm
core_server_fixture.cppm
fixtures.cppm
completion_test.cppm
json_flow_test.cppm
@@ -28,6 +29,7 @@ set(SOURCES
interpreter_test.cppm
provider_misc_test.cppm
provider_surface_test.cppm
text_coordinates_test.cppm
../../src/tree-sitter/parser.c
../../src/tree-sitter/scanner.c)
@@ -48,6 +50,7 @@ target_sources(
${CMAKE_CURRENT_SOURCE_DIR}/../../src
FILES ${CMAKE_CURRENT_SOURCE_DIR}/test_main.cppm
${CMAKE_CURRENT_SOURCE_DIR}/../test_lsp_any/test_framework.cppm
${CMAKE_CURRENT_SOURCE_DIR}/core_server_fixture.cppm
${CMAKE_CURRENT_SOURCE_DIR}/fixtures.cppm
${CMAKE_CURRENT_SOURCE_DIR}/completion_test.cppm
${CMAKE_CURRENT_SOURCE_DIR}/json_flow_test.cppm
@@ -56,14 +59,19 @@ target_sources(
${CMAKE_CURRENT_SOURCE_DIR}/interpreter_test.cppm
${CMAKE_CURRENT_SOURCE_DIR}/provider_misc_test.cppm
${CMAKE_CURRENT_SOURCE_DIR}/provider_surface_test.cppm
${CMAKE_CURRENT_SOURCE_DIR}/text_coordinates_test.cppm
../../src/bridge/glaze.cppm
../../src/bridge/spdlog.cppm
../../src/bridge/taskflow.cppm
../../src/bridge/tree_sitter.cppm
../../src/bridge/win32_stdio.cppm
../../src/utils/args_parser.cppm
../../src/utils/string.cppm
../../src/utils/text_coordinates.cppm
../../src/core/dispatcher.cppm
../../src/core/server.cppm
../../src/scheduler/async_executor.cppm
../../src/manager/bootstrap.cppm
../../src/manager/event_bus.cppm
../../src/manager/events.cppm
../../src/manager/detail/text_document.cppm
@@ -138,9 +146,6 @@ target_sources(
../../src/provider/completion_item/resolve.cppm
../../src/provider/initialize/initialize.cppm
../../src/provider/initialized/initialized.cppm
../../src/provider/shutdown/shutdown.cppm
../../src/provider/exit/exit.cppm
../../src/provider/cancel_request/cancel_request.cppm
../../src/provider/trace/set_trace.cppm
../../src/provider/client/register_capability.cppm
../../src/provider/client/unregister_capability.cppm
@@ -64,12 +64,12 @@ namespace lsp::test::provider
{
struct ProviderEnv
{
scheduler::AsyncExecutor scheduler{ 1 };
scheduler::async_executor::AsyncExecutor scheduler{ 1 };
manager::ManagerHub hub{};
core::ExecutionContext context;
ProviderEnv()
: context([](core::ServerLifecycleEvent) {}, scheduler, hub)
: context(scheduler, hub)
{
hub.Initialize();
}
@@ -0,0 +1,134 @@
module;
export module lsp.test.provider.core_server_fixture;
import spdlog;
import std;
import lsp.codec.facade;
import lsp.core.server;
import lsp.protocol;
import lsp.provider.base.interface;
import lsp.provider.text_document.did_open;
export namespace lsp::test::provider
{
int RunCoreServerFixture();
}
namespace lsp::test::provider
{
class FixtureInitialize final : public core::IRequestProvider
{
public:
std::string GetMethod() const override
{
return "initialize";
}
std::string GetProviderName() const override
{
return "FixtureInitialize";
}
std::string ProvideResponse(const protocol::RequestMessage& request,
core::ExecutionContext&) override
{
protocol::ResponseMessage response;
response.id = request.id;
response.result = protocol::LSPAny(protocol::LSPObject{});
return codec::Serialize(response).value();
}
};
class FixtureBlock final : public core::IRequestProvider
{
public:
std::string GetMethod() const override
{
return "test/block";
}
std::string GetProviderName() const override
{
return "FixtureBlock";
}
std::string ProvideResponse(const protocol::RequestMessage& request,
core::ExecutionContext& context) override
{
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(2);
while (!context.GetStopToken().stop_requested() &&
std::chrono::steady_clock::now() < deadline)
{
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
protocol::ResponseMessage response;
response.id = request.id;
response.result = protocol::LSPAny(protocol::string("completed"));
return codec::Serialize(response).value();
}
};
class FixtureThrow final : public core::IRequestProvider
{
public:
std::string GetMethod() const override
{
return "test/throw";
}
std::string GetProviderName() const override
{
return "FixtureThrow";
}
std::string ProvideResponse(const protocol::RequestMessage&,
core::ExecutionContext&) override
{
throw std::runtime_error("fixture request failure");
}
};
class FixtureThrowNotification final : public core::INotificationProvider
{
public:
std::string GetMethod() const override
{
return "test/throwNotification";
}
std::string GetProviderName() const override
{
return "FixtureThrowNotification";
}
void HandleNotification(const protocol::NotificationMessage&,
core::ExecutionContext&) override
{
throw std::runtime_error("fixture notification failure");
}
};
int RunCoreServerFixture()
{
spdlog::set_level(spdlog::level::off);
core::LspServer server(
std::cin,
std::cout,
[](core::RequestDispatcher& dispatcher) {
dispatcher.RegisterRequestProvider(std::make_shared<FixtureInitialize>());
dispatcher.RegisterRequestProvider(std::make_shared<FixtureBlock>());
dispatcher.RegisterRequestProvider(std::make_shared<FixtureThrow>());
dispatcher.RegisterNotificationProvider(
std::make_shared<::lsp::provider::text_document::DidOpen>());
dispatcher.RegisterNotificationProvider(
std::make_shared<FixtureThrowNotification>());
},
2,
"");
return server.Run();
}
}
@@ -33,12 +33,12 @@ namespace lsp::test::provider
{
struct ProviderEnv
{
scheduler::AsyncExecutor scheduler{ 1 };
scheduler::async_executor::AsyncExecutor scheduler{ 1 };
manager::ManagerHub hub{};
core::ExecutionContext context;
ProviderEnv()
: context([](core::ServerLifecycleEvent) {}, scheduler, hub)
: context(scheduler, hub)
{
hub.Initialize();
}
@@ -34,12 +34,12 @@ namespace lsp::test::provider
struct ProviderEnv
{
scheduler::AsyncExecutor scheduler{ 4 };
scheduler::async_executor::AsyncExecutor scheduler{ 4 };
manager::ManagerHub hub{};
core::ExecutionContext context;
ProviderEnv()
: context([](core::ServerLifecycleEvent) {}, scheduler, hub)
: context(scheduler, hub)
{
hub.Initialize();
}
@@ -207,4 +207,3 @@ namespace lsp::test::provider
return result;
}
}
@@ -35,12 +35,12 @@ namespace lsp::test::provider
{
struct ProviderEnv
{
scheduler::AsyncExecutor scheduler{ 1 };
scheduler::async_executor::AsyncExecutor scheduler{ 1 };
manager::ManagerHub hub{};
core::ExecutionContext context;
ProviderEnv()
: context([](core::ServerLifecycleEvent) {}, scheduler, hub)
: context(scheduler, hub)
{
hub.Initialize();
}
@@ -49,15 +49,13 @@ namespace lsp::test::provider
struct ProviderEnv
{
scheduler::AsyncExecutor scheduler{ 1 };
scheduler::async_executor::AsyncExecutor scheduler{ 1 };
manager::ManagerHub hub{};
core::RequestDispatcher dispatcher{};
core::RequestDispatcher dispatcher{ scheduler, hub };
ProviderEnv()
{
hub.Initialize();
dispatcher.SetRequestScheduler(&scheduler);
dispatcher.SetManagerHub(&hub);
provider::RegisterAllProviders(dispatcher);
}
};
@@ -1225,7 +1223,7 @@ namespace lsp::test::provider
auto code_action_tree = env.hub.parser().GetTree(code_action_uri);
auto code_action_diagnostics = BuildDiagnosticsFromSyntaxErrors(code_action_tree, code_action_content);
env.scheduler.Submit("json_cancel_me", []() -> std::optional<std::string> {
env.scheduler.Submit("json_cancel_me", [](std::stop_token) -> std::optional<std::string> {
std::this_thread::sleep_for(std::chrono::milliseconds(200));
return std::string("done");
});
@@ -77,10 +77,7 @@ import lsp.provider.workspace.will_delete_files;
import lsp.provider.workspace.will_rename_files;
import lsp.provider.workspace_symbol.resolve;
import lsp.provider.text_document.publish_diagnostics;
import lsp.provider.shutdown.shutdown;
import lsp.provider.cancel_request.cancel_request;
import lsp.provider.trace.set_trace;
import lsp.provider.exit.exit;
import lsp.core.dispatcher;
import lsp.manager.manager_hub;
import lsp.manager.symbol;
@@ -160,13 +157,8 @@ export namespace lsp::test::provider
static TestResult TestExecuteCommandProvider();
static TestResult TestWillFileOperationsProviders();
static TestResult TestWorkspaceSymbolResolveProvider();
static TestResult TestShutdownProvider();
static TestResult TestCancelRequestProvider();
static TestResult TestSetTraceProvider();
static TestResult TestExitProvider();
};
int RunExitProviderChild();
}
namespace lsp::test::provider
@@ -175,13 +167,11 @@ namespace lsp::test::provider
{
struct ProviderEnv
{
std::vector<core::ServerLifecycleEvent> events;
scheduler::AsyncExecutor scheduler{ 1 };
scheduler::async_executor::AsyncExecutor scheduler{ 1 };
manager::ManagerHub hub{};
core::ExecutionContext context;
ProviderEnv()
: context([this](core::ServerLifecycleEvent event) { events.push_back(event); }, scheduler, hub)
ProviderEnv() : context(scheduler, hub)
{
hub.Initialize();
}
@@ -365,10 +355,7 @@ namespace lsp::test::provider
runner.addTest("workspace executeCommand provider", TestExecuteCommandProvider);
runner.addTest("workspace will file operations providers", TestWillFileOperationsProviders);
runner.addTest("workspaceSymbol/resolve provider", TestWorkspaceSymbolResolveProvider);
runner.addTest("shutdown provider", TestShutdownProvider);
runner.addTest("cancel request provider", TestCancelRequestProvider);
runner.addTest("setTrace provider", TestSetTraceProvider);
runner.addTest("exit provider", TestExitProvider);
}
TestResult ProviderMiscTests::TestInitializeProvider()
@@ -434,8 +421,6 @@ namespace lsp::test::provider
});
assertTrue(found_workspace, "Workspace symbols should be indexed");
assertTrue(!env.events.empty(), "Initialize should emit lifecycle event");
assertTrue(env.events.back() == core::ServerLifecycleEvent::kInitialized, "Initialize should emit initialized");
return result;
}
@@ -1425,8 +1410,7 @@ namespace lsp::test::provider
auto tooltip_it = resolved.find("tooltip");
assertTrue(tooltip_it != resolved.end(), "InlayHint resolve should set tooltip");
assertTrue(tooltip_it->second.Is<protocol::string>(), "InlayHint tooltip should be string");
assertEqual(std::string("param: int"), tooltip_it->second.Get<protocol::string>(),
"InlayHint tooltip should use detail");
assertEqual(std::string("param: int"), tooltip_it->second.Get<protocol::string>(), "InlayHint tooltip should use detail");
return result;
}
@@ -2233,7 +2217,6 @@ namespace lsp::test::provider
const auto& stored_obj = stored.Get<protocol::LSPObject>();
assertTrue(stored_obj.contains("tsl"), "stored settings should include tsl section");
assertTrue(env.events.empty(), "didChangeConfiguration should not trigger lifecycle events");
return result;
}
@@ -2761,7 +2744,6 @@ namespace lsp::test::provider
provider.HandleNotification(notification, env.context);
}
assertTrue(env.events.empty(), "window message notifications should not trigger lifecycle events");
return result;
}
@@ -2781,7 +2763,6 @@ namespace lsp::test::provider
::lsp::provider::telemetry::Event provider;
provider.HandleNotification(notification, env.context);
assertTrue(env.events.empty(), "telemetry/event should not trigger lifecycle events");
return result;
}
@@ -2816,7 +2797,6 @@ namespace lsp::test::provider
::lsp::provider::text_document::PublishDiagnostics provider;
provider.HandleNotification(notification, env.context);
assertTrue(env.events.empty(), "publishDiagnostics should not trigger lifecycle events");
return result;
}
@@ -2909,10 +2889,8 @@ namespace lsp::test::provider
{
protocol::LSPObject diagnostic;
diagnostic["range"] = protocol::LSPObject{
{ "start", protocol::LSPObject{ { "line", static_cast<protocol::integer>(error.location.start_line) },
{ "character", static_cast<protocol::integer>(error.location.start_column) } } },
{ "end", protocol::LSPObject{ { "line", static_cast<protocol::integer>(error.location.end_line) },
{ "character", static_cast<protocol::integer>(error.location.end_column) } } },
{ "start", protocol::LSPObject{ { "line", static_cast<protocol::integer>(error.location.start_line) }, { "character", static_cast<protocol::integer>(error.location.start_column) } } },
{ "end", protocol::LSPObject{ { "line", static_cast<protocol::integer>(error.location.end_line) }, { "character", static_cast<protocol::integer>(error.location.end_column) } } },
};
diagnostic["message"] = error.message;
diagnostics.emplace_back(std::move(diagnostic));
@@ -3396,63 +3374,6 @@ namespace lsp::test::provider
return result;
}
TestResult ProviderMiscTests::TestShutdownProvider()
{
TestResult result{ "", true, "ok" };
ProviderEnv env;
auto path = FixturePath("rename_case.tsl");
auto content = ReadTextFile(path);
auto uri = ToUri(path);
OpenDocument(env.hub, uri, content, 1);
protocol::RequestMessage request;
request.id = "shutdown";
request.method = "shutdown";
::lsp::provider::Shutdown provider;
auto json = provider.ProvideResponse(request, env.context);
auto response = ParseResponse(json);
assertTrue(!response.error.has_value(), "Shutdown should not return error");
assertTrue(env.events.size() >= 1, "Shutdown should emit lifecycle event");
assertTrue(env.events.back() == core::ServerLifecycleEvent::kShuttingDown, "Shutdown should emit shutting down");
assertFalse(env.hub.documents().GetContent(uri).has_value(), "Shutdown should clear documents");
return result;
}
TestResult ProviderMiscTests::TestCancelRequestProvider()
{
TestResult result{ "", true, "ok" };
ProviderEnv env;
std::atomic<bool> started{ false };
env.scheduler.Submit("cancel_me", [&started]() -> std::optional<std::string> {
started.store(true);
std::this_thread::sleep_for(std::chrono::milliseconds(200));
return std::string("done");
});
while (!started.load())
{
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
protocol::CancelParams params;
params.id = std::string("cancel_me");
protocol::NotificationMessage notification;
notification.method = "$/cancelRequest";
notification.params = codec::ToLSPAny(params);
::lsp::provider::CancelRequest provider;
provider.HandleNotification(notification, env.context);
env.scheduler.WaitAll();
auto stats = env.scheduler.GetStatistics();
assertEqual(std::size_t(1), static_cast<std::size_t>(stats.cancelled),
"CancelRequest should mark task cancelled");
return result;
}
TestResult ProviderMiscTests::TestSetTraceProvider()
{
TestResult result{ "", true, "ok" };
@@ -3484,24 +3405,4 @@ namespace lsp::test::provider
return result;
}
TestResult ProviderMiscTests::TestExitProvider()
{
TestResult result{ "", true, "ok" };
auto exe = ExecutablePath();
assertTrue(!exe.empty(), "ExecutablePath should be set");
std::string command = "\"" + exe + "\" --exit-provider";
int code = std::system(command.c_str());
assertEqual(0, code, "Exit should return code 0");
return result;
}
int RunExitProviderChild()
{
ProviderEnv env;
::lsp::provider::Exit provider;
protocol::NotificationMessage notification;
notification.method = "exit";
provider.HandleNotification(notification, env.context);
return 1;
}
}
@@ -12,16 +12,13 @@ import lsp.manager.manager_hub;
import lsp.scheduler.async_executor;
import lsp.test.provider.fixtures;
import lsp.provider.cancel_request.cancel_request;
import lsp.provider.code_action.resolve;
import lsp.provider.code_lens.resolve;
import lsp.provider.completion_item.resolve;
import lsp.provider.document_link.resolve;
import lsp.provider.exit.exit;
import lsp.provider.initialize.initialize;
import lsp.provider.initialized.initialized;
import lsp.provider.inlay_hint.resolve;
import lsp.provider.shutdown.shutdown;
import lsp.provider.trace.set_trace;
import lsp.provider.call_hierarchy.incoming_calls;
import lsp.provider.call_hierarchy.outgoing_calls;
@@ -113,12 +110,11 @@ namespace lsp::test::provider
struct ProviderEnv
{
scheduler::AsyncExecutor scheduler{ 1 };
scheduler::async_executor::AsyncExecutor scheduler{ 1 };
manager::ManagerHub hub{};
core::ExecutionContext context;
ProviderEnv()
: context([](core::ServerLifecycleEvent) {}, scheduler, hub)
ProviderEnv() : context(scheduler, hub)
{
hub.Initialize();
}
@@ -129,10 +125,8 @@ namespace lsp::test::provider
const std::string& expected_name)
{
Provider provider;
assertEqual(expected_method, provider.GetMethod(),
"GetMethod mismatch for " + expected_name);
assertEqual(expected_name, provider.GetProviderName(),
"GetProviderName mismatch for " + expected_name);
assertEqual(expected_method, provider.GetMethod(), "GetMethod mismatch for " + expected_name);
assertEqual(expected_name, provider.GetProviderName(), "GetProviderName mismatch for " + expected_name);
}
template<typename Provider>
@@ -174,60 +168,59 @@ namespace lsp::test::provider
TestResult result{ "", true, "ok" };
CheckProviderMetadata<provider::Initialize>("initialize", "Initialize");
CheckProviderMetadata<provider::Shutdown>("shutdown", "Shutdown");
CheckProviderMetadata<provider::completion_item::Resolve>("completionItem/resolve", "CompletionItemResolve");
CheckProviderMetadata<provider::text_document::Completion>("textDocument/completion", "TextDocumentCompletion");
CheckProviderMetadata<provider::text_document::Definition>("textDocument/definition", "TextDocumentDefinition");
CheckProviderMetadata<provider::text_document::Rename>("textDocument/rename", "TextDocumentRename");
CheckProviderMetadata<provider::text_document::References>("textDocument/references", "TextDocumentReferences");
CheckProviderMetadata<provider::text_document::SemanticTokensRange>("textDocument/semanticTokens/range",
"TextDocumentSemanticTokensRange");
"TextDocumentSemanticTokensRange");
CheckProviderMetadata<provider::text_document::SemanticTokensFull>("textDocument/semanticTokens/full",
"SemanticTokensFull");
"SemanticTokensFull");
CheckProviderMetadata<provider::text_document::SemanticTokensFullDelta>("textDocument/semanticTokens/full/delta",
"SemanticTokensFullDelta");
"SemanticTokensFullDelta");
CheckProviderMetadata<provider::text_document::Hover>("textDocument/hover", "TextDocumentHover");
CheckProviderMetadata<provider::text_document::Formatting>("textDocument/formatting", "TextDocumentFormatting");
CheckProviderMetadata<provider::text_document::RangeFormatting>("textDocument/rangeFormatting",
"TextDocumentRangeFormatting");
"TextDocumentRangeFormatting");
CheckProviderMetadata<provider::text_document::OnTypeFormatting>("textDocument/onTypeFormatting",
"TextDocumentOnTypeFormatting");
"TextDocumentOnTypeFormatting");
CheckProviderMetadata<provider::text_document::DocumentSymbol>("textDocument/documentSymbol",
"TextDocumentDocumentSymbol");
"TextDocumentDocumentSymbol");
CheckProviderMetadata<provider::text_document::DocumentLink>("textDocument/documentLink",
"TextDocumentDocumentLink");
"TextDocumentDocumentLink");
CheckProviderMetadata<provider::text_document::DocumentHighlight>("textDocument/documentHighlight",
"TextDocumentDocumentHighlight");
"TextDocumentDocumentHighlight");
CheckProviderMetadata<provider::text_document::DocumentColor>("textDocument/documentColor",
"TextDocumentDocumentColor");
"TextDocumentDocumentColor");
CheckProviderMetadata<provider::text_document::ColorPresentation>("textDocument/colorPresentation",
"TextDocumentColorPresentation");
"TextDocumentColorPresentation");
CheckProviderMetadata<provider::text_document::CodeLens>("textDocument/codeLens", "TextDocumentCodeLens");
CheckProviderMetadata<provider::text_document::CodeAction>("textDocument/codeAction", "TextDocumentCodeAction");
CheckProviderMetadata<provider::text_document::PrepareTypeHierarchy>("textDocument/prepareTypeHierarchy",
"TextDocumentPrepareTypeHierarchy");
"TextDocumentPrepareTypeHierarchy");
CheckProviderMetadata<provider::text_document::PrepareRename>("textDocument/prepareRename",
"TextDocumentPrepareRename");
"TextDocumentPrepareRename");
CheckProviderMetadata<provider::text_document::PrepareCallHierarchy>("textDocument/prepareCallHierarchy",
"TextDocumentPrepareCallHierarchy");
"TextDocumentPrepareCallHierarchy");
CheckProviderMetadata<provider::text_document::TypeDefinition>("textDocument/typeDefinition",
"TextDocumentTypeDefinition");
CheckProviderMetadata<provider::text_document::Implementation>("textDocument/implementation",
"TextDocumentImplementation");
"TextDocumentImplementation");
CheckProviderMetadata<provider::text_document::SelectionRange>("textDocument/selectionRange",
"TextDocumentSelectionRange");
"TextDocumentSelectionRange");
CheckProviderMetadata<provider::text_document::SignatureHelp>("textDocument/signatureHelp",
"TextDocumentSignatureHelp");
"TextDocumentSignatureHelp");
CheckProviderMetadata<provider::text_document::InlayHint>("textDocument/inlayHint", "TextDocumentInlayHint");
CheckProviderMetadata<provider::text_document::InlineValue>("textDocument/inlineValue",
"TextDocumentInlineValue");
"TextDocumentInlineValue");
CheckProviderMetadata<provider::text_document::LinkedEditingRange>("textDocument/linkedEditingRange",
"TextDocumentLinkedEditingRange");
"TextDocumentLinkedEditingRange");
CheckProviderMetadata<provider::text_document::Moniker>("textDocument/moniker", "TextDocumentMoniker");
CheckProviderMetadata<provider::text_document::Diagnostic>("textDocument/diagnostic",
"TextDocumentDiagnostic");
"TextDocumentDiagnostic");
CheckProviderMetadata<provider::text_document::FoldingRange>("textDocument/foldingRange",
"TextDocumentFoldingRange");
"TextDocumentFoldingRange");
CheckProviderMetadata<provider::code_action::Resolve>("codeAction/resolve", "CodeActionResolve");
CheckProviderMetadata<provider::code_lens::Resolve>("codeLens/resolve", "CodeLensResolve");
CheckProviderMetadata<provider::document_link::Resolve>("documentLink/resolve", "DocumentLinkResolve");
@@ -236,12 +229,12 @@ namespace lsp::test::provider
CheckProviderMetadata<provider::type_hierarchy::Supertypes>("typeHierarchy/supertypes", "TypeHierarchySupertypes");
CheckProviderMetadata<provider::type_hierarchy::Subtypes>("typeHierarchy/subtypes", "TypeHierarchySubtypes");
CheckProviderMetadata<provider::call_hierarchy::IncomingCalls>("callHierarchy/incomingCalls",
"CallHierarchyIncomingCalls");
"CallHierarchyIncomingCalls");
CheckProviderMetadata<provider::call_hierarchy::OutgoingCalls>("callHierarchy/outgoingCalls",
"CallHierarchyOutgoingCalls");
"CallHierarchyOutgoingCalls");
CheckProviderMetadata<provider::workspace::Diagnostic>("workspace/diagnostic", "WorkspaceDiagnostic");
CheckProviderMetadata<provider::workspace::ExecuteCommand>("workspace/executeCommand",
"WorkspaceExecuteCommand");
"WorkspaceExecuteCommand");
CheckProviderMetadata<provider::workspace::WillCreateFiles>("workspace/willCreateFiles", "WorkspaceWillCreateFiles");
CheckProviderMetadata<provider::workspace::WillDeleteFiles>("workspace/willDeleteFiles", "WorkspaceWillDeleteFiles");
CheckProviderMetadata<provider::workspace::WillRenameFiles>("workspace/willRenameFiles", "WorkspaceWillRenameFiles");
@@ -264,17 +257,15 @@ namespace lsp::test::provider
CheckProviderMetadata<provider::telemetry::Event>("telemetry/event", "TelemetryEvent");
CheckProviderMetadata<provider::text_document::PublishDiagnostics>("textDocument/publishDiagnostics", "TextDocumentPublishDiagnostics");
CheckProviderMetadata<provider::workspace::DidChangeConfiguration>("workspace/didChangeConfiguration",
"WorkspaceDidChangeConfiguration");
"WorkspaceDidChangeConfiguration");
CheckProviderMetadata<provider::workspace::DidChangeWatchedFiles>("workspace/didChangeWatchedFiles",
"WorkspaceDidChangeWatchedFiles");
"WorkspaceDidChangeWatchedFiles");
CheckProviderMetadata<provider::workspace::DidChangeWorkspaceFolders>("workspace/didChangeWorkspaceFolders",
"WorkspaceDidChangeWorkspaceFolders");
"WorkspaceDidChangeWorkspaceFolders");
CheckProviderMetadata<provider::workspace::DidCreateFiles>("workspace/didCreateFiles", "WorkspaceDidCreateFiles");
CheckProviderMetadata<provider::workspace::DidDeleteFiles>("workspace/didDeleteFiles", "WorkspaceDidDeleteFiles");
CheckProviderMetadata<provider::workspace::DidRenameFiles>("workspace/didRenameFiles", "WorkspaceDidRenameFiles");
CheckProviderMetadata<provider::Initialized>("initialized", "Initialized");
CheckProviderMetadata<provider::Exit>("exit", "Exit");
CheckProviderMetadata<provider::CancelRequest>("$/cancelRequest", "CancelRequest");
CheckProviderMetadata<provider::SetTrace>("$/setTrace", "SetTrace");
CheckProviderMetadata<provider::text_document::DidOpen>("textDocument/didOpen", "TextDocumentDidOpen");
CheckProviderMetadata<provider::text_document::DidChange>("textDocument/didChange", "TextDocumentDidChange");
@@ -287,7 +278,6 @@ namespace lsp::test::provider
{
TestResult result{ "", true, "ok" };
CheckRequestResponse<provider::Shutdown>();
CheckRequestResponse<provider::completion_item::Resolve>();
CheckRequestResponse<provider::text_document::Completion>();
CheckRequestResponse<provider::text_document::Definition>();
@@ -357,7 +347,6 @@ namespace lsp::test::provider
TestResult result{ "", true, "ok" };
CheckNotificationHandler<provider::Initialized>(std::nullopt);
CheckNotificationHandler<provider::Exit>(std::nullopt);
CheckNotificationHandler<provider::workspace::DidChangeConfiguration>(std::nullopt);
CheckNotificationHandler<provider::workspace::DidChangeWatchedFiles>(std::nullopt);
CheckNotificationHandler<provider::workspace::DidChangeWorkspaceFolders>(std::nullopt);
@@ -248,7 +248,7 @@ namespace lsp::test::provider
input_file << input_payload;
}
std::string command = "\"" + server_path.string() + "\" --log=off --use-stdio < \"" +
std::string command = "\"" + server_path.string() + "\" --log=off < \"" +
input_path.string() + "\" > \"" + output_path.string() + "\"";
int exit_code = std::system(command.c_str());
assertEqual(0, exit_code, "tsl-server should exit successfully");
+6 -2
View File
@@ -6,12 +6,14 @@ import std;
import lsp.test.framework;
import lsp.test.provider.completion;
import lsp.test.provider.core_server_fixture;
import lsp.test.provider.definitions;
import lsp.test.provider.interpreter;
import lsp.test.provider.json_flow;
import lsp.test.provider.json_provider_coverage;
import lsp.test.provider.misc;
import lsp.test.provider.surface;
import lsp.test.provider.text_coordinates;
import lsp.test.provider.fixtures;
export int Run(int argc, char** argv)
@@ -25,9 +27,9 @@ export int Run(int argc, char** argv)
{
std::string_view arg(argv[i]);
if (arg == "--exit-provider")
if (arg == "--core-server-fixture")
{
return lsp::test::provider::RunExitProviderChild();
return lsp::test::provider::RunCoreServerFixture();
}
constexpr std::string_view kInterpreterPrefix = "--interpreter=";
@@ -57,6 +59,8 @@ export int Run(int argc, char** argv)
lsp::test::provider::JsonProviderCoverageTests::Register(runner);
std::cout << " - Other provider tests" << std::endl;
lsp::test::provider::ProviderMiscTests::Register(runner);
std::cout << " - Text coordinate tests" << std::endl;
lsp::test::provider::TextCoordinatesTests::Register(runner);
std::cout << " - Provider surface tests" << std::endl;
lsp::test::provider::ProviderSurfaceTests::Register(runner);
@@ -0,0 +1,118 @@
module;
export module lsp.test.provider.text_coordinates;
import std;
import lsp.protocol;
import lsp.test.framework;
import lsp.utils.text_coordinates;
export namespace lsp::test::provider
{
class TextCoordinatesTests
{
public:
static void Register(TestRunner& runner);
private:
static TestResult TestAsciiAndBmpPositions();
static TestResult TestSupplementaryPlanePositions();
static TestResult TestMultilineAndClampedPositions();
static TestResult TestMalformedUtf8IsBounded();
static TestResult TestCalculateEndPointUsesByteColumns();
static TestResult TestBytePointsToUtf16Positions();
};
}
namespace lsp::test::provider
{
namespace
{
void ExpectPosition(const utils::text_coordinates::BytePosition& actual,
protocol::uinteger offset,
std::uint32_t row,
std::uint32_t column)
{
assertEqual(offset, actual.offset, "byte offset should match");
assertEqual(row, actual.point.row, "Tree-sitter row should match");
assertEqual(column, actual.point.column, "Tree-sitter byte column should match");
}
void ExpectLspPosition(const protocol::Position& actual,
protocol::uinteger line,
protocol::uinteger character)
{
assertEqual(line, actual.line, "LSP line should match");
assertEqual(character, actual.character, "LSP UTF-16 character should match");
}
}
void TextCoordinatesTests::Register(TestRunner& runner)
{
runner.addTest("text coordinates convert ASCII and BMP positions", TestAsciiAndBmpPositions);
runner.addTest("text coordinates convert supplementary-plane positions", TestSupplementaryPlanePositions);
runner.addTest("text coordinates clamp multiline positions", TestMultilineAndClampedPositions);
runner.addTest("text coordinates bound malformed UTF-8", TestMalformedUtf8IsBounded);
runner.addTest("text coordinates calculate byte end points", TestCalculateEndPointUsesByteColumns);
runner.addTest("text coordinates convert byte points to UTF-16 positions", TestBytePointsToUtf16Positions);
}
TestResult TextCoordinatesTests::TestAsciiAndBmpPositions()
{
const protocol::string content = "A中Z";
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 0 }, content), 0U, 0U, 0U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 1 }, content), 1U, 0U, 1U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 2 }, content), 4U, 0U, 4U);
assertEqual(4U, utils::text_coordinates::ToOffset({ 0, 2 }, content), "ToOffset should use the paired conversion");
return { "", true, "ok" };
}
TestResult TextCoordinatesTests::TestSupplementaryPlanePositions()
{
const protocol::string content = "A😀Z";
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 1 }, content), 1U, 0U, 1U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 2 }, content), 1U, 0U, 1U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 3 }, content), 5U, 0U, 5U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 4 }, content), 6U, 0U, 6U);
return { "", true, "ok" };
}
TestResult TextCoordinatesTests::TestMultilineAndClampedPositions()
{
const protocol::string content = "中x\n😀y";
ExpectPosition(utils::text_coordinates::ToBytePosition({ 1, 0 }, content), 5U, 1U, 0U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 1, 2 }, content), 9U, 1U, 4U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 1, 100 }, content), 10U, 1U, 5U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 9, 0 }, content), 10U, 1U, 5U);
return { "", true, "ok" };
}
TestResult TextCoordinatesTests::TestMalformedUtf8IsBounded()
{
const protocol::string content{ 'A', static_cast<char>(0xF0), static_cast<char>(0x9F), 'Z' };
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 2 }, content), 2U, 0U, 2U);
ExpectPosition(utils::text_coordinates::ToBytePosition({ 0, 100 }, content), 4U, 0U, 4U);
return { "", true, "ok" };
}
TestResult TextCoordinatesTests::TestCalculateEndPointUsesByteColumns()
{
const auto end = utils::text_coordinates::CalculateEndPoint("中\n😀x", { 2U, 3U });
assertEqual(3U, end.row, "newline should advance the Tree-sitter row");
assertEqual(5U, end.column, "multibyte text should advance the byte column");
return { "", true, "ok" };
}
TestResult TextCoordinatesTests::TestBytePointsToUtf16Positions()
{
const protocol::string content = "A中😀Z\n😀x";
ExpectLspPosition(utils::text_coordinates::ToPosition({ 0U, 0U }, content), 0U, 0U);
ExpectLspPosition(utils::text_coordinates::ToPosition({ 0U, 4U }, content), 0U, 2U);
ExpectLspPosition(utils::text_coordinates::ToPosition({ 0U, 8U }, content), 0U, 4U);
ExpectLspPosition(utils::text_coordinates::ToPosition({ 1U, 4U }, content), 1U, 2U);
ExpectLspPosition(utils::text_coordinates::ToPosition({ 0U, 6U }, content), 0U, 2U);
return { "", true, "ok" };
}
}
+1 -1
View File
@@ -2,5 +2,5 @@ import lsp.test.scheduler.async_executor;
int main()
{
return Run();
return lsp::test::scheduler::async_executor::Run();
}
@@ -10,6 +10,9 @@ using namespace std::chrono_literals;
namespace
{
using lsp::scheduler::async_executor::AsyncExecutor;
using lsp::scheduler::async_executor::TaskStatus;
void Expect(bool condition, const std::string& message)
{
if (!condition)
@@ -42,7 +45,7 @@ namespace
}
catch (const std::exception& e)
{
failures++;
++failures;
std::cout << "[FAIL] " << entry.name << " -> " << e.what() << std::endl;
}
}
@@ -53,112 +56,337 @@ namespace
private:
std::vector<Entry> entries_;
};
}
export int Run()
{
SchedulerTestSuite suite;
suite.Add("Completes basic task", [] {
lsp::scheduler::AsyncExecutor executor(2);
std::mutex callback_mutex;
std::optional<std::string> callback_result;
bool callback_cancelled = false;
auto handle = executor.Submit("task.simple", []() -> std::optional<std::string> {
std::this_thread::sleep_for(5ms);
return std::string("done"); }, [&](const std::optional<std::string>& result, bool cancelled) {
std::lock_guard<std::mutex> lk(callback_mutex);
callback_result = result;
callback_cancelled = cancelled; });
Expect(handle.Valid(), "Task handle should be valid");
Expect(handle.Wait(), "Handle wait should succeed");
class BlockingCallbackCopy
{
public:
BlockingCallbackCopy(std::latch& copy_started, std::latch& release_copy) :
copy_started_(copy_started), release_copy_(release_copy)
{
std::lock_guard<std::mutex> lk(callback_mutex);
Expect(callback_result.has_value(), "Task result should be reported by callback");
Expect(!callback_cancelled, "Callback should not be marked cancelled");
Expect(callback_result.value() == "done", "Task result should match");
}
executor.WaitAll();
auto stats = executor.GetStatistics();
Expect(stats.completed == 1, "Completed count should be 1");
Expect(stats.cancelled == 0, "Cancelled count should be 0");
Expect(stats.failed == 0, "Failed count should be 0");
});
BlockingCallbackCopy(const BlockingCallbackCopy& other) :
copy_started_(other.copy_started_), release_copy_(other.release_copy_)
{
copy_started_.count_down();
release_copy_.wait();
}
suite.Add("Cancels running task via handle", [] {
lsp::scheduler::AsyncExecutor executor(1);
std::atomic<bool> callback_cancelled{ false };
BlockingCallbackCopy(BlockingCallbackCopy&&) = default;
auto handle = executor.Submit("task.cancel", []() -> std::optional<std::string> {
std::this_thread::sleep_for(50ms);
return std::string("late"); }, [&](const std::optional<std::string>&, bool cancelled) { callback_cancelled.store(cancelled, std::memory_order_relaxed); });
void operator()(const lsp::scheduler::async_executor::TaskResult&) const {}
std::this_thread::sleep_for(10ms);
Expect(handle.Cancel(), "Handle cancel should report success");
private:
std::latch& copy_started_;
std::latch& release_copy_;
};
}
Expect(handle.Wait(), "Wait after cancellation should still succeed");
executor.WaitAll();
Expect(callback_cancelled.load(std::memory_order_relaxed), "Callback should observe cancellation");
export namespace lsp::test::scheduler::async_executor
{
int Run()
{
SchedulerTestSuite suite;
auto stats = executor.GetStatistics();
Expect(stats.cancelled == 1, "Cancelled count should be 1");
});
suite.Add("Replaces existing task with same id", [] {
lsp::scheduler::AsyncExecutor executor(2);
std::atomic<bool> first_cancelled{ false };
auto first = executor.Submit("task.duplicate", []() -> std::optional<std::string> {
std::this_thread::sleep_for(30ms);
return std::string("first"); }, [&](const std::optional<std::string>&, bool cancelled) {
if (cancelled)
first_cancelled.store(true, std::memory_order_relaxed); });
std::this_thread::sleep_for(5ms);
std::atomic<bool> second_completed{ false };
auto second = executor.Submit("task.duplicate", [&]() -> std::optional<std::string> {
std::this_thread::sleep_for(20ms);
second_completed.store(true, std::memory_order_relaxed);
return std::string("second");
suite.Add("Rejects operations on a default handle", [] {
lsp::scheduler::async_executor::TaskHandle handle;
Expect(!handle.Valid(), "default handle should be invalid");
Expect(!handle.Cancel(), "invalid handle cancellation should fail");
Expect(!handle.Wait().has_value(), "invalid handle wait should be empty");
Expect(!handle.TryGetResult().has_value(), "invalid handle result should be empty");
});
Expect(second.Wait(), "Second handle should finish");
Expect(first.Wait(), "First handle should finish");
executor.WaitAll();
Expect(first_cancelled.load(std::memory_order_relaxed), "First callback should report cancellation");
suite.Add("Retains completed task result through handle", [] {
AsyncExecutor executor{ 1 };
auto handle = executor.Submit("fast", [](std::stop_token) {
return std::optional<std::string>{ "done" };
});
auto stats = executor.GetStatistics();
Expect(stats.completed == 1, "One task should complete successfully");
Expect(stats.cancelled >= 1, "At least one task should be cancelled");
Expect(second_completed.load(std::memory_order_relaxed), "Second task body should run");
});
auto result = handle.Wait();
Expect(handle.Valid(), "completed handle should remain valid");
Expect(result && result->status == TaskStatus::kCompleted, "task should complete");
Expect(result->value == "done", "task value should remain available");
Expect(handle.TryGetResult().has_value(), "completed result should be non-blocking");
Expect(!handle.Cancel(), "completed task cancellation should fail");
});
suite.Add("Handles many concurrent submissions", [] {
constexpr int kTaskCount = 32;
lsp::scheduler::AsyncExecutor executor(8);
std::atomic<int> callback_count{ 0 };
suite.Add("Waits for callback completion", [] {
AsyncExecutor executor{ 1 };
std::latch callback_started{ 1 };
std::latch release_callback{ 1 };
auto handle = executor.Submit(
"callback.wait",
[](std::stop_token) { return std::optional<std::string>{ "done" }; },
[&](const lsp::scheduler::async_executor::TaskResult&) {
callback_started.count_down();
release_callback.wait();
});
for (int i = 0; i < kTaskCount; ++i)
{
executor.Submit("task." + std::to_string(i), [i]() -> std::optional<std::string> {
std::this_thread::sleep_for(2ms);
return std::to_string(i * i); }, [&](const std::optional<std::string>& result, bool cancelled) {
Expect(!cancelled, "Concurrent tasks should not be cancelled");
Expect(result.has_value(), "Concurrent task should produce a result");
callback_count.fetch_add(1, std::memory_order_relaxed); });
}
callback_started.wait();
auto waiter = std::async(std::launch::async, [&handle] { return handle.Wait(); });
const bool blocked = waiter.wait_for(20ms) == std::future_status::timeout;
const bool unavailable = !handle.TryGetResult().has_value();
release_callback.count_down();
auto result = waiter.get();
executor.WaitAll();
auto stats = executor.GetStatistics();
Expect(stats.completed == kTaskCount, "All concurrent tasks should complete");
Expect(stats.cancelled == 0 && stats.failed == 0, "No concurrent tasks should fail or cancel");
Expect(callback_count.load(std::memory_order_relaxed) == kTaskCount, "Callbacks should run for every task");
});
Expect(blocked, "wait should block until callback returns");
Expect(unavailable, "result should remain unavailable during callback");
Expect(result && result->status == TaskStatus::kCompleted, "wait should return task result");
});
const int failures = suite.RunAll();
return failures == 0 ? 0 : 1;
suite.Add("Skips a queued task cancelled through its handle", [] {
AsyncExecutor executor{ 1 };
std::latch blocker_started{ 1 };
std::latch release_blocker{ 1 };
auto blocker = executor.Submit("blocker", [&](std::stop_token) {
blocker_started.count_down();
release_blocker.wait();
return std::optional<std::string>{};
});
blocker_started.wait();
std::atomic<bool> ran{ false };
auto queued = executor.Submit("queued", [&](std::stop_token) {
ran = true;
return std::optional<std::string>{};
});
const bool first_cancel = queued.Cancel();
const bool second_cancel = queued.Cancel();
release_blocker.count_down();
auto queued_result = queued.Wait();
auto blocker_result = blocker.Wait();
Expect(first_cancel, "first queued cancellation should succeed");
Expect(!second_cancel, "repeated cancellation should fail");
Expect(queued_result && queued_result->status == TaskStatus::kCancelled, "queued task should cancel");
Expect(!ran, "cancelled queued closure must not execute");
Expect(blocker_result && blocker_result->status == TaskStatus::kCompleted, "blocker should finish");
});
suite.Add("Running task observes stop token", [] {
AsyncExecutor executor{ 1 };
std::latch started{ 1 };
std::atomic<bool> observed_stop{ false };
std::atomic<bool> release_task{ false };
auto handle = executor.Submit("running", [&](std::stop_token stop_token) {
started.count_down();
while (!stop_token.stop_requested() && !release_task.load())
std::this_thread::yield();
observed_stop = stop_token.stop_requested();
return std::optional<std::string>{ "ignored" };
});
started.wait();
const bool cancelled = executor.Cancel("running");
release_task = true;
auto result = handle.Wait();
Expect(cancelled, "ID cancellation should find current task");
Expect(observed_stop, "running task should observe stop token");
Expect(result && result->status == TaskStatus::kCancelled, "running task should finish cancelled");
Expect(!result->value.has_value(), "cancelled task should not expose a value");
});
suite.Add("Does not hold state lock while requesting stop", [] {
AsyncExecutor executor{ 1 };
lsp::scheduler::async_executor::TaskHandle handle;
std::latch allow_callback_registration{ 1 };
std::latch callback_registered{ 1 };
std::atomic<bool> reentrant_cancel_blocked{ false };
std::thread reentrant_cancel;
handle = executor.Submit("stop.callback", [&](std::stop_token stop_token) {
allow_callback_registration.wait();
std::stop_callback callback(stop_token, [&] {
auto reentrant_done = std::make_shared<std::promise<void>>();
auto reentrant_future = reentrant_done->get_future();
reentrant_cancel = std::thread([&, reentrant_done] {
handle.Cancel();
reentrant_done->set_value();
});
reentrant_cancel_blocked = reentrant_future.wait_for(20ms) == std::future_status::timeout;
});
callback_registered.count_down();
while (!stop_token.stop_requested())
std::this_thread::yield();
return std::optional<std::string>{};
});
allow_callback_registration.count_down();
callback_registered.wait();
const bool cancelled = handle.Cancel();
reentrant_cancel.join();
auto result = handle.Wait();
Expect(cancelled, "initial cancellation should succeed");
Expect(!reentrant_cancel_blocked, "stop callback should reenter cancellation without blocking on state lock");
Expect(result && result->status == TaskStatus::kCancelled, "task should finish cancelled");
});
suite.Add("Old same-ID handle cannot cancel replacement", [] {
AsyncExecutor executor{ 2 };
std::latch first_started{ 1 };
std::atomic<bool> release_first{ false };
auto first = executor.Submit("duplicate", [&](std::stop_token stop_token) {
first_started.count_down();
while (!stop_token.stop_requested() && !release_first.load())
std::this_thread::yield();
return std::optional<std::string>{ "first" };
});
first_started.wait();
std::latch second_started{ 1 };
std::latch release_second{ 1 };
std::atomic<bool> second_saw_stop{ false };
auto second = executor.Submit("duplicate", [&](std::stop_token stop_token) {
second_started.count_down();
release_second.wait();
second_saw_stop = stop_token.stop_requested();
return std::optional<std::string>{ "second" };
});
second_started.wait();
const bool old_cancel = first.Cancel();
release_first = true;
release_second.count_down();
auto first_result = first.Wait();
auto second_result = second.Wait();
Expect(!old_cancel, "already replaced task should reject repeated cancellation");
Expect(first_result && first_result->status == TaskStatus::kCancelled, "replaced task should cancel");
Expect(second_result && second_result->status == TaskStatus::kCompleted, "replacement should complete");
Expect(!second_saw_stop, "old handle must not stop replacement");
});
suite.Add("Counts every active same-ID instance", [] {
AsyncExecutor executor{ 2 };
std::latch first_started{ 1 };
std::latch second_started{ 1 };
std::latch release_tasks{ 1 };
auto first = executor.Submit("same", [&](std::stop_token) {
first_started.count_down();
release_tasks.wait();
return std::optional<std::string>{};
});
first_started.wait();
auto second = executor.Submit("same", [&](std::stop_token) {
second_started.count_down();
release_tasks.wait();
return std::optional<std::string>{};
});
second_started.wait();
const auto active_count = executor.GetRunningTaskCount();
release_tasks.count_down();
executor.WaitAll();
Expect(active_count == 2, "both same-ID instances should be active");
Expect(executor.GetRunningTaskCount() == 0, "all instances should unregister");
Expect(first.Wait().has_value() && second.Wait().has_value(), "both handles should retain results");
});
suite.Add("WaitAll observes a task before Taskflow submission", [] {
AsyncExecutor executor{ 1 };
std::latch callback_copy_started{ 1 };
std::latch release_callback_copy{ 1 };
AsyncExecutor::TaskCallback callback{
BlockingCallbackCopy(callback_copy_started, release_callback_copy)
};
auto submitter = std::async(std::launch::async, [&] {
return executor.Submit(
"submission.window",
[](std::stop_token) { return std::optional<std::string>{}; },
std::move(callback));
});
callback_copy_started.wait();
auto waiter = std::async(std::launch::async, [&] { executor.WaitAll(); });
const bool waited_for_registered_task = waiter.wait_for(20ms) == std::future_status::timeout;
release_callback_copy.count_down();
auto handle = submitter.get();
waiter.get();
auto result = handle.Wait();
Expect(waited_for_registered_task, "WaitAll should not return while a registered task is being submitted");
Expect(result && result->status == TaskStatus::kCompleted, "submitted task should complete");
});
suite.Add("Normalizes zero concurrency", [] {
AsyncExecutor executor{ 0 };
auto result = executor.Submit("zero", [](std::stop_token) {
return std::optional<std::string>{ "done" };
})
.Wait();
Expect(result && result->status == TaskStatus::kCompleted, "zero concurrency should use one worker");
});
suite.Add("Preserves task exception", [] {
AsyncExecutor executor{ 1 };
auto result = executor.Submit("failure", [](std::stop_token) -> std::optional<std::string> {
throw std::logic_error("original failure");
})
.Wait();
Expect(result && result->status == TaskStatus::kFailed, "throwing task should fail");
Expect(result->error != nullptr, "failed task should retain exception");
try
{
std::rethrow_exception(result->error);
}
catch (const std::logic_error& e)
{
Expect(std::string_view(e.what()) == "original failure", "original exception should be preserved");
}
});
suite.Add("Ignores callback exception in task result", [] {
AsyncExecutor executor{ 1 };
auto handle = executor.Submit(
"callback.failure",
[](std::stop_token) { return std::optional<std::string>{ "done" }; },
[](const lsp::scheduler::async_executor::TaskResult&) {
throw std::runtime_error("callback failure");
});
auto result = handle.Wait();
Expect(result && result->status == TaskStatus::kCompleted, "callback failure should not alter task status");
Expect(result->value == "done", "callback failure should not alter task value");
Expect(result->error == nullptr, "callback failure should not become task error");
});
suite.Add("Reports terminal metrics", [] {
AsyncExecutor executor{ 1 };
std::latch blocker_started{ 1 };
std::latch release_blocker{ 1 };
auto completed = executor.Submit("complete", [&](std::stop_token) {
blocker_started.count_down();
release_blocker.wait();
return std::optional<std::string>{ "done" };
});
blocker_started.wait();
auto cancelled = executor.Submit("cancel", [](std::stop_token) {
return std::optional<std::string>{};
});
auto failed = executor.Submit("fail", [](std::stop_token) -> std::optional<std::string> {
throw std::runtime_error("failed");
});
const bool cancel_requested = cancelled.Cancel();
release_blocker.count_down();
executor.WaitAll();
const auto metrics = executor.GetStatistics();
Expect(cancel_requested, "queued task cancellation should succeed");
Expect(completed.Wait()->status == TaskStatus::kCompleted, "completed status should match metrics");
Expect(cancelled.Wait()->status == TaskStatus::kCancelled, "cancelled status should match metrics");
Expect(failed.Wait()->status == TaskStatus::kFailed, "failed status should match metrics");
Expect(metrics.running == 0, "no task should remain active");
Expect(metrics.submitted == 3, "submitted count should be 3");
Expect(metrics.completed == 1, "completed count should be 1");
Expect(metrics.cancelled == 1, "cancelled count should be 1");
Expect(metrics.failed == 1, "failed count should be 1");
});
const int failures = suite.RunAll();
return failures == 0 ? 0 : 1;
}
}
+42 -1
View File
@@ -2,6 +2,16 @@
## Current Focus
- 已完成严格 LSP core 生命周期:initialize/shutdown/exit 使用显式状态机,
EOF 与非法 framing 返回非零退出码,launcher 透传服务器退出码
- 已完成 `AsyncExecutor` 协作式取消重构;任务句柄稳定持有结果,按实例取消,
请求 ID 区分整数与字符串,运行中任务通过 `std::stop_token` 退出
- 已修复 LSP UTF-16 与 UTF-8/Tree-sitter 字节坐标的双向转换;增量编辑和
syntax diagnostics 对中文与 emoji 使用一致位置
- JSON-RPC 错误、请求异常和 LSP transport framing 均使用严格结构化处理,
不再保留手写空 JSON、生命周期 Provider 或 staged rollout 开关
- 已完成参数解析与启动错误处理重构;CLI 参数严格校验,日志默认固定到
stderr,启动失败使用受控退出码
- 已完成 Conan 依赖升级;`lsp-server` 当前使用 ConanCenter 可见的最新
`glaze``taskflow``spdlog``fmt``tree-sitter` recipe
@@ -17,6 +27,19 @@
- `glaze` 已升级到 `7.4.0``taskflow` 已升级到 `4.0.0`
- 已适配 CMake 4.3 `import std` gate、Taskflow 4.0 头文件依赖、
completion `LSPAny data` 反序列化和 scheduler completion callback 顺序
- `ArgsParser` 已替换为无状态 `ParseArgs`,并补充参数单元测试与 CLI
启动回归测试
- LSP 测试脚本、VSCode 和 Vim 已移除废弃的日志输出选择参数
- 文本坐标转换新增 ASCII、中文、emoji、跨行、越界和无效 UTF-8
回归测试;syntax diagnostics 已在 `didOpen` 后实际发布 UTF-16 坐标
- scheduler 使用 current/active 双索引准确处理重复 ID;`WaitAll` 覆盖登记到
Taskflow 入队之间的窗口,取消不在状态锁内触发 stop callback
- 系统库、workspace 加载、文件索引和 workspace folder 枚举已传递
`std::stop_token`,并在文件边界检查取消
- core 新增真实 stdio 生命周期、JSON-RPC 错误、请求取消、严格 framing 和
diagnostics 回归;`tsl-server``test_scheduler``test_provider` 构建通过
- `test_cli_startup``test_core_server``test_scheduler` 和 LSP transport
smoke 通过;`test_provider` 为 100/110,失败集合与本次 core 改动前一致
## Next Steps
@@ -25,6 +48,10 @@
3. Playbook 升级保持 subtree + sync 的固定顺序
4. 另行处理既有脚本类测试失败:
`test_ast_script``test_symbol_script``test_semantic_script`
5. 后续任务处理 `--interpreter=~/...` 的主目录展开
6. 另行处理 `test_lsp_json` 的 rename 响应缺少 `result`;旧服务器二进制
同样可复现
7. 另行修复 `test_provider` 的 10 个既有 Provider 行为断言
## Open Risks
@@ -32,12 +59,22 @@
影响 subtree、status 和 stash 类命令
- `lsp-server/test/test_tree_sitter/test` 下部分 TSF fixture 仍存在解析失败,
会导致脚本类 AST / symbol / semantic CTest 失败
- 系统 CMake 4.4 的 `import std` 实验门值尚未适配;本次使用 CMake 4.3.4
完成构建验证
- `test_provider` 仍有 documentHighlight、implementation、configuration、
refresh、client capability、workDoneProgress、code action、executeCommand 和
will file operations 共 10 个既有失败
- Clang 22 在 C++ Modules 全量重编译期间会随机 frontend exit 139;本次在
Glaze 实例化和 `interpreter_test.cppm` 出现,使用相同 `-j1` 重试成功
- `vscode/bin/tsl-server` 当前是未拉取的 Git LFS 指针,根目录 LSP 冒烟脚本
只能完成语法与参数静态验证
## Workflow State
<!-- workflow-state:start -->
phase: done
plan: docs/superpowers/plans/2026-05-24-conan-dependency-upgrade.md
spec: docs/superpowers/specs/2026-07-13-lsp-core-lifecycle-design.md
plan: docs/superpowers/plans/2026-07-13-lsp-core-lifecycle.md
executor: executing-plans
constraints: karpathy-guidelines,.agents,AGENT_RULES
<!-- workflow-state:end -->
@@ -47,4 +84,8 @@ constraints: karpathy-guidelines,.agents,AGENT_RULES
<!-- plan-status:start -->
- [x] `2026-05-24-playbook-strong-alignment.md` done
- [x] `2026-05-24-conan-dependency-upgrade.md` done: known-failures:test_ast_script,test_symbol_script,test_semantic_script
- [x] `2026-07-11-args-parser-startup-errors.md` done
- [x] `2026-07-12-text-coordinates-utf16.md` done
- [x] `2026-07-12-async-executor-cancellation.md` done
- [x] `2026-07-13-lsp-core-lifecycle.md` done
<!-- plan-status:end -->
+1 -1
View File
@@ -45,7 +45,7 @@ tsl-devkit/
**运行测试**
```bash
ctest --test-dir lsp-server/build/clang-linux/Release --output-on-failure
ctest --test-dir /var/tmp/tsl-devkit/clang-linux/Release --output-on-failure
npm --prefix vscode run compile
```
+1 -1
View File
@@ -2,4 +2,4 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
JSON='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":null,"rootUri":"file:///tmp","capabilities":{}}}'
LENGTH=$(echo -n "$JSON" | wc -c)
printf "Content-Length: %d\r\n\r\n%s" $LENGTH "$JSON" | "$SCRIPT_DIR/../vscode/bin/tsl-server" --log=verbose --log-stderr
printf "Content-Length: %d\r\n\r\n%s" $LENGTH "$JSON" | "$SCRIPT_DIR/../vscode/bin/tsl-server" --log=trace
+1 -2
View File
@@ -48,7 +48,7 @@ TSL 提供完整的 LSP 支持,包括代码补全、错误检查、跳转定
"languageserver": {
"tsl-server": {
"command": "tsl-server",
"args": ["--log=info", "--log=stderr", "--interpreter=~/tsl64"],
"args": ["--log=info", "--interpreter=~/tsl64"],
"filetypes": ["tsl", "tsf"]
}
}
@@ -58,7 +58,6 @@ TSL 提供完整的 LSP 支持,包括代码补全、错误检查、跳转定
| 参数 | 说明 | 默认值 |
| -------------------- | -------------------------------- | -------- |
| `--log` | 日志级别 (trace/info/warn/error) | `info` |
| `--log-stderr` | 输出日志到标准错误 | - |
| `--log-file=PATH` | 输出日志到文件 | - |
| `--interpreter=PATH` | TSL 解释器路径 | 自动检测 |
+2 -2
View File
@@ -64,7 +64,7 @@ code --install-extension tsl-tools-3.0.0.vsix
| 配置项 | 类型 | 默认值 | 描述 |
| --- | --- | --- | --- |
| `tsl.server.executable` | `string` | `""` | TSL 语言服务器可执行文件路径<br>_留空自动检测_ |
| `tsl.server.arguments` | `string[]` | `["--log=trace", "--log-stderr"]` | 语言服务器启动参数<br>_使用 `./tsl-server --help` 查看所有选项_ |
| `tsl.server.arguments` | `string[]` | `["--log=info"]` | 语言服务器启动参数<br>_使用 `./tsl-server --help` 查看所有选项_ |
| `tsl.interpreter.executable` | `string` | `""` | TSL 解释器路径<br>_用于识别 funcext 下的 .tsf 文件_ |
### 配置示例
@@ -72,7 +72,7 @@ code --install-extension tsl-tools-3.0.0.vsix
```json
{
"tsl.server.executable": "/usr/local/bin/tsl-server",
"tsl.server.arguments": ["--log=info", "--log-stderr"],
"tsl.server.arguments": ["--log=info"],
"tsl.interpreter.executable": "/usr/local/bin/tsl"
}
```
+1 -2
View File
@@ -61,8 +61,7 @@
"tsl.server.arguments": {
"type": "array",
"default": [
"--log=info",
"--log-stderr"
"--log=info"
],
"description": "Arguments for TSL server",
"items": {
-1
View File
@@ -118,7 +118,6 @@ export function activate(context: vscode.ExtensionContext) {
const runArgs = [...serverArguments]
const debugArgs = serverArguments.map(arg => arg.startsWith('--log=') ? '--log=trace' : arg)
if (!debugArgs.some(arg => arg.startsWith('--log='))) debugArgs.push('--log=trace')
if (!debugArgs.includes('--log-stderr')) debugArgs.push('--log-stderr')
const serverOptions: ServerOptions = {
run: { command: serverExe, transport: TransportKind.stdio, args: runArgs },