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:
@@ -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<> {
|
||||
|
||||
Reference in New Issue
Block a user