feat(server): improve configuration file handling (#423)

## Summary

- **`[[rules]]`**: TOML array-of-tables config for per-file compilation
flag rules with glob pattern matching (`append`/`remove`). Patterns are
pre-compiled at config load time. Rules whose patterns all fail to
compile are dropped entirely (no silent no-op entries), and rules now
apply uniformly to every compilation — including the header-context
fallback path used when editing a header without its own CDB entry.
- **CDB auto-scan**: Default search scans workspace root + all immediate
subdirectories for `compile_commands.json`, replacing the hardcoded
directory list.
- **LSP `initializationOptions`**: Clients can pass config as JSON via
the LSP initialize request; priority is `initializationOptions >
clice.toml > defaults`.
- **XDG cache paths**: Default cache/index/logging paths prefer
`$XDG_CACHE_HOME/clice/<workspace-hash>/`; fall back to
`$HOME/.cache/clice/<hash>/`, then `<workspace>/.clice/`.
- **`${workspace}` substitution**: supported in `cache_dir`,
`index_dir`, `logging_dir`, and every `compile_commands_paths` entry.
No-op when `workspace_root` is empty.
- **Partial config support**: All TOML/JSON fields are optional via
`kota::meta::defaulted<T>`, so minimal config files work correctly.
- **Detailed diagnostics**: malformed `clice.toml` now logs line, column
and parser description (via toml++ direct parse); a malformed workspace
config surfaces a clear fallback warning instead of silently reverting
to defaults.

## Test plan

- [x] 28 unit tests for config (full suite 545 unit tests pass, Debug)
- [x] 119 integration tests pass
- [x] 2 smoke tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* XDG-based, workspace-scoped project cache (PCH/PCM and header caches
moved under project cache) with workspace fallback
* Initialization options JSON can override config (takes precedence over
file/defaults)
* Per-file pattern rules to append/remove compile flags; expanded
discovery of compilation databases (multiple paths)

* **Refactor**
* Configuration fields reorganized under a project scope; runtime
behavior now respects project-scoped values

* **Tests**
* New unit and integration tests for config parsing, rule matching, and
persistent cache behavior

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ykiko
2026-04-20 00:21:31 +08:00
committed by GitHub
parent 3fa653bcaf
commit 17e68010a0
21 changed files with 989 additions and 150 deletions

View File

@@ -60,47 +60,66 @@ kota::task<> MasterServer::load_workspace() {
if(workspace_root.empty())
co_return;
if(!workspace.config.cache_dir.empty()) {
auto ec = llvm::sys::fs::create_directories(workspace.config.cache_dir);
auto& cfg = workspace.config.project;
if(!cfg.cache_dir.empty()) {
auto ec = llvm::sys::fs::create_directories(cfg.cache_dir);
if(ec) {
LOG_WARN("Failed to create cache directory {}: {}",
workspace.config.cache_dir,
std::string_view(cfg.cache_dir),
ec.message());
} else {
LOG_INFO("Cache directory: {}", workspace.config.cache_dir);
LOG_INFO("Cache directory: {}", std::string_view(cfg.cache_dir));
}
for(auto* subdir: {"cache/pch", "cache/pcm"}) {
auto dir = path::join(workspace.config.cache_dir, subdir);
auto ec2 = llvm::sys::fs::create_directories(dir);
if(ec2) {
auto dir = path::join(cfg.cache_dir, subdir);
if(auto ec2 = llvm::sys::fs::create_directories(dir))
LOG_WARN("Failed to create {}: {}", dir, ec2.message());
}
}
// Clean up stale files first, then load — load_cache() only restores
// entries still listed in cache.json, so cleanup won't delete live files.
workspace.cleanup_cache();
workspace.load_cache();
}
// Discover compile_commands.json: configured paths first, then auto-scan.
std::string cdb_path;
if(!workspace.config.compile_commands_path.empty()) {
if(llvm::sys::fs::exists(workspace.config.compile_commands_path)) {
cdb_path = workspace.config.compile_commands_path;
} else {
LOG_WARN("Configured compile_commands_path not found: {}",
workspace.config.compile_commands_path);
}
}
if(cdb_path.empty()) {
for(auto* subdir: {"build", "cmake-build-debug", "cmake-build-release", "out", "."}) {
auto candidate = path::join(workspace_root, subdir, "compile_commands.json");
for(auto& configured: cfg.compile_commands_paths) {
// Each entry can be a file or a directory containing compile_commands.json.
if(llvm::sys::fs::is_directory(configured)) {
auto candidate = path::join(configured, "compile_commands.json");
if(llvm::sys::fs::exists(candidate)) {
cdb_path = std::move(candidate);
break;
}
} else if(llvm::sys::fs::exists(configured)) {
cdb_path = configured;
break;
} else {
LOG_WARN("Configured compile_commands_path not found: {}", configured);
}
}
// Auto-scan: workspace root + all immediate subdirectories.
if(cdb_path.empty()) {
auto try_candidate = [&](llvm::StringRef dir) -> bool {
auto candidate = path::join(dir, "compile_commands.json");
if(llvm::sys::fs::exists(candidate)) {
cdb_path = std::move(candidate);
return true;
}
return false;
};
if(!try_candidate(workspace_root)) {
std::error_code ec;
for(llvm::sys::fs::directory_iterator it(workspace_root, ec), end; it != end && !ec;
it.increment(ec)) {
if(it->type() == llvm::sys::fs::file_type::directory_file) {
if(try_candidate(it->path()))
break;
}
}
}
}
@@ -112,7 +131,15 @@ kota::task<> MasterServer::load_workspace() {
auto count = workspace.cdb.load(cdb_path);
LOG_INFO("Loaded CDB from {} with {} entries", cdb_path, count);
auto report = scan_dependency_graph(workspace.cdb, workspace.path_pool, workspace.dep_graph);
auto report = scan_dependency_graph(workspace.cdb,
workspace.path_pool,
workspace.dep_graph,
/*cache=*/nullptr,
[this](llvm::StringRef path,
std::vector<std::string>& append,
std::vector<std::string>& remove) {
workspace.config.match_rules(path, append, remove);
});
workspace.dep_graph.build_reverse_map();
auto unresolved = report.includes_found - report.includes_resolved;
@@ -131,14 +158,13 @@ kota::task<> MasterServer::load_workspace() {
report.includes_found,
accuracy,
report.waves);
if(unresolved > 0) {
if(unresolved > 0)
LOG_WARN("{} unresolved includes", unresolved);
}
workspace.build_module_map();
indexer.load(workspace.config.index_dir);
indexer.load(cfg.index_dir);
if(workspace.config.enable_indexing) {
if(*cfg.enable_indexing) {
for(auto& entry: workspace.cdb.get_entries()) {
auto file = workspace.cdb.resolve_path(entry.file);
auto server_id = workspace.path_pool.intern(file);
@@ -164,6 +190,14 @@ void MasterServer::register_handlers() {
workspace_root = uri_to_path(*init.root_uri);
}
// Capture initializationOptions as raw JSON for config loading.
if(init.initialization_options.has_value()) {
auto json =
kota::codec::json::to_json<kota::ipc::lsp_config>(*init.initialization_options);
if(json)
init_options_json = std::move(*json);
}
lifecycle = ServerLifecycle::Initialized;
LOG_INFO("Initialized with workspace: {}", workspace_root);
@@ -244,27 +278,47 @@ void MasterServer::register_handlers() {
});
peer.on_notification([this](const protocol::InitializedParams& params) {
workspace.config = CliceConfig::load_from_workspace(workspace_root);
// Config priority: initializationOptions > clice.toml > defaults.
// Load the workspace config (with defaults applied) first, then overlay
// any initializationOptions on top so fields not mentioned in the JSON
// keep the values from clice.toml — kotatsu's deserializer only touches
// fields that are present in the input.
workspace.config = Config::load_from_workspace(workspace_root);
if(!init_options_json.empty()) {
if(auto ov = kota::codec::json::parse(init_options_json, workspace.config); !ov) {
LOG_WARN("Failed to apply initializationOptions: {}", ov.error().to_string());
} else {
// Re-run apply_defaults so overridden strings get workspace
// substitution and `compiled_rules` is rebuilt if `rules`
// changed. Defaults are gated on zero/empty sentinels, so
// existing values from the overlay are preserved.
workspace.config.apply_defaults(workspace_root);
LOG_INFO("Applied initializationOptions overlay");
}
init_options_json.clear();
}
if(!workspace.config.logging_dir.empty()) {
auto& cfg = workspace.config.project;
if(!cfg.logging_dir.empty()) {
auto now = std::chrono::system_clock::now();
auto pid = llvm::sys::Process::getProcessId();
auto session_dir = path::join(workspace.config.logging_dir,
std::format("{:%Y-%m-%d_%H-%M-%S}_{}", now, pid));
auto session_dir =
path::join(cfg.logging_dir, std::format("{:%Y-%m-%d_%H-%M-%S}_{}", now, pid));
logging::file_logger("master", session_dir, logging::options);
session_log_dir = session_dir;
}
LOG_INFO("Server ready (stateful={}, stateless={}, idle={}ms)",
workspace.config.stateful_worker_count,
workspace.config.stateless_worker_count,
workspace.config.idle_timeout_ms);
cfg.stateful_worker_count.value,
cfg.stateless_worker_count.value,
*cfg.idle_timeout_ms);
WorkerPoolOptions pool_opts;
pool_opts.self_path = self_path;
pool_opts.stateful_count = workspace.config.stateful_worker_count;
pool_opts.stateless_count = workspace.config.stateless_worker_count;
pool_opts.worker_memory_limit = workspace.config.worker_memory_limit;
pool_opts.stateful_count = cfg.stateful_worker_count;
pool_opts.stateless_count = cfg.stateless_worker_count;
pool_opts.worker_memory_limit = cfg.worker_memory_limit;
pool_opts.log_dir = session_log_dir;
if(!pool.start(pool_opts)) {
LOG_ERROR("Failed to start worker pool");
@@ -292,7 +346,7 @@ void MasterServer::register_handlers() {
lifecycle = ServerLifecycle::Exited;
LOG_INFO("Exit notification received");
indexer.save(workspace.config.index_dir);
indexer.save(workspace.config.project.index_dir);
workspace.save_cache();
loop.schedule([this]() -> kota::task<> {