Files
clice/tests/unit/server/config_tests.cpp
ykiko 939ab6d0d4 feat(server): concurrent background indexing with priority control (#432)
## Summary

- Rewrite serial background indexing to concurrent dispatch (up to
`stateless_worker_count / 2` parallel tasks)
- Add depth-counted pause/resume mechanism: completion and
signature-help handlers pause new index dispatches to prioritize user
requests
- Report indexing progress via LSP `$/progress` notifications
(percentage + file count)
- Lower thread scheduling priority (`nice +10`) for index tasks in
stateless workers via RAII `ScopedNice` guard

## Test plan

- [x] `pixi run format` — no changes
- [x] `pixi run unit-test Debug` — 551 passed, 9 skipped (pre-existing)
- [x] `pixi run smoke-test Debug` — 2/2 passed
- [x] `pixi run integration-test Debug` — 121 passed, 3 failed (all
pre-existing on main: header_context x2, staleness x1)
- [ ] Manual test: open a large project (e.g. LLVM), verify progress bar
appears and completion remains responsive during indexing

🤖 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**
  * Pause/resume controls for background indexing
* Concurrent, adaptive background indexing with configurable concurrency
* LSP progress reporting (create/begin/report/end) and updated
completion metrics

* **Behavior Change**
* Code completion and signature help temporarily pause indexing for
responsiveness
* Background indexing runs with reduced scheduling priority on
non-Windows and logs "files dispatched" at finish

* **Tests**
* Test client fixture defaults init options and sets workspace cache dir
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 13:28:59 +08:00

502 lines
17 KiB
C++

#include <cstdlib>
#include "test/temp_dir.h"
#include "test/test.h"
#include "server/config.h"
#include "support/filesystem.h"
#include "kota/codec/json/json.h"
#include "kota/codec/toml/toml.h"
namespace clice::testing {
// POSIX setenv/unsetenv don't exist on Windows; map to _putenv_s
// (passing an empty value to _putenv_s removes the variable).
static void set_env(const char* name, const char* value) {
#ifdef _WIN32
::_putenv_s(name, value);
#else
::setenv(name, value, 1);
#endif
}
static void unset_env(const char* name) {
#ifdef _WIN32
::_putenv_s(name, "");
#else
::unsetenv(name);
#endif
}
TEST_SUITE(Config) {
TEST_CASE(ParsePartialProject) {
auto result = kota::codec::toml::parse<ProjectConfig>(R"(cache_dir = "/tmp/test")");
EXPECT_TRUE(result.has_value());
EXPECT_EQ(std::string_view(result->cache_dir), "/tmp/test");
EXPECT_EQ(result->clang_tidy.value, false);
EXPECT_EQ(result->max_active_file.value, 0);
EXPECT_FALSE(result->enable_indexing.has_value());
EXPECT_FALSE(result->idle_timeout_ms.has_value());
}
TEST_CASE(ParseConfigRule) {
auto result = kota::codec::toml::parse<ConfigRule>(R"(
patterns = ["**/*.cpp"]
append = ["-std=c++20"]
)");
EXPECT_TRUE(result.has_value());
EXPECT_EQ(result->patterns.size(), 1u);
EXPECT_EQ(result->patterns[0], "**/*.cpp");
EXPECT_EQ(result->append[0], "-std=c++20");
EXPECT_TRUE(result->remove.empty());
}
TEST_CASE(ParseFullConfig) {
auto result = kota::codec::toml::parse<Config>(R"(
[project]
cache_dir = "/tmp/test"
clang_tidy = true
enable_indexing = false
[[rules]]
patterns = ["**/*.cpp"]
append = ["-std=c++20"]
)");
EXPECT_TRUE(result.has_value());
EXPECT_EQ(std::string_view(result->project.cache_dir), "/tmp/test");
EXPECT_EQ(result->project.clang_tidy.value, true);
EXPECT_EQ(*result->project.enable_indexing, false);
EXPECT_EQ(result->rules.size(), 1u);
EXPECT_EQ(result->rules[0].patterns[0], "**/*.cpp");
}
TEST_CASE(ParseEmptyConfig) {
auto result = kota::codec::toml::parse<Config>("");
EXPECT_TRUE(result.has_value());
EXPECT_TRUE(result->rules.empty());
EXPECT_TRUE(std::string_view(result->project.cache_dir).empty());
}
TEST_CASE(ParseOnlyRules) {
auto result = kota::codec::toml::parse<Config>(R"(
[[rules]]
patterns = ["*.h"]
remove = ["-Werror"]
)");
EXPECT_TRUE(result.has_value());
EXPECT_EQ(result->rules.size(), 1u);
EXPECT_EQ(result->rules[0].patterns[0], "*.h");
EXPECT_EQ(result->rules[0].remove[0], "-Werror");
EXPECT_TRUE(std::string_view(result->project.cache_dir).empty());
}
TEST_CASE(MatchRulesBasic) {
Config config;
config.rules.push_back(ConfigRule{
.patterns = {"**/*.cpp"},
.append = {"-std=c++20"},
.remove = {"-std=c++17"},
});
config.apply_defaults("");
std::vector<std::string> append, remove;
config.match_rules("/src/foo.cpp", append, remove);
EXPECT_EQ(append.size(), 1u);
EXPECT_EQ(append[0], "-std=c++20");
EXPECT_EQ(remove.size(), 1u);
EXPECT_EQ(remove[0], "-std=c++17");
}
TEST_CASE(MatchRulesNoMatch) {
Config config;
config.rules.push_back(ConfigRule{
.patterns = {"**/*.cpp"},
.append = {"-DFOO"},
});
config.apply_defaults("");
std::vector<std::string> append, remove;
config.match_rules("/src/foo.h", append, remove);
EXPECT_TRUE(append.empty());
EXPECT_TRUE(remove.empty());
}
TEST_CASE(MatchRulesMultiple) {
Config config;
config.rules.push_back(ConfigRule{
.patterns = {"**/*.cpp"},
.append = {"-DCPP"},
});
config.rules.push_back(ConfigRule{
.patterns = {"**/test_*.cpp"},
.append = {"-DTEST"},
});
config.apply_defaults("");
std::vector<std::string> append, remove;
config.match_rules("/src/test_foo.cpp", append, remove);
EXPECT_EQ(append.size(), 2u);
EXPECT_EQ(append[0], "-DCPP");
EXPECT_EQ(append[1], "-DTEST");
}
TEST_CASE(ApplyDefaults) {
Config config;
config.apply_defaults("/workspace");
EXPECT_EQ(*config.project.enable_indexing, true);
EXPECT_EQ(*config.project.idle_timeout_ms, 3000);
EXPECT_EQ(config.project.max_active_file.value, 8);
EXPECT_EQ(config.project.stateful_worker_count.value, 2u);
EXPECT_GE(config.project.stateless_worker_count.value, 2u);
EXPECT_FALSE(config.project.cache_dir.empty());
EXPECT_FALSE(config.project.index_dir.empty());
EXPECT_FALSE(config.project.logging_dir.empty());
}
TEST_CASE(ApplyDefaultsEmptyWorkspace) {
Config config;
config.apply_defaults("");
EXPECT_TRUE(config.project.cache_dir.empty());
EXPECT_TRUE(config.project.index_dir.empty());
EXPECT_TRUE(config.project.logging_dir.empty());
}
TEST_CASE(ApplyDefaultsPreserveSet) {
Config config;
config.project.cache_dir = "/custom";
config.project.enable_indexing = false;
config.apply_defaults("/workspace");
EXPECT_EQ(std::string_view(config.project.cache_dir), "/custom");
EXPECT_EQ(*config.project.enable_indexing, false);
}
TEST_CASE(LoadFromJson) {
auto result = Config::load_from_json(R"({
"project": {
"cache_dir": "/opt/cache",
"clang_tidy": true,
"enable_indexing": false
},
"rules": [
{ "patterns": ["**/*.cpp"], "append": ["-DFOO"] }
]
})",
"/workspace");
EXPECT_TRUE(result.has_value());
EXPECT_EQ(std::string_view(result->project.cache_dir), "/opt/cache");
EXPECT_EQ(result->project.clang_tidy.value, true);
EXPECT_EQ(*result->project.enable_indexing, false);
EXPECT_EQ(result->rules.size(), 1u);
EXPECT_EQ(result->compiled_rules.size(), 1u);
}
TEST_CASE(LoadFromJsonInvalid) {
auto result = Config::load_from_json("{not valid json", "/workspace");
EXPECT_FALSE(result.has_value());
}
TEST_CASE(LoadMalformedToml) {
TempDir tmp;
tmp.touch("clice.toml", "[project\nbroken");
auto result = Config::load(tmp.path("clice.toml"), tmp.root.str().str());
EXPECT_FALSE(result.has_value());
}
TEST_CASE(LoadMissingFile) {
auto result = Config::load("/nonexistent/clice.toml", "/workspace");
EXPECT_FALSE(result.has_value());
}
TEST_CASE(WorkspaceVarSubst) {
Config config;
config.project.cache_dir = "${workspace}/cache";
config.project.index_dir = "${workspace}/idx";
config.project.logging_dir = "${workspace}/logs";
config.project.compile_commands_paths = {"${workspace}/build"};
config.apply_defaults("/my/ws");
EXPECT_EQ(std::string_view(config.project.cache_dir), "/my/ws/cache");
EXPECT_EQ(std::string_view(config.project.index_dir), "/my/ws/idx");
EXPECT_EQ(std::string_view(config.project.logging_dir), "/my/ws/logs");
EXPECT_EQ(config.project.compile_commands_paths[0], "/my/ws/build");
}
TEST_CASE(XdgCacheDir) {
TempDir tmp;
auto cache_base = tmp.path("xdg");
set_env("XDG_CACHE_HOME", cache_base.c_str());
Config config;
config.apply_defaults("/some/ws");
unset_env("XDG_CACHE_HOME");
// Normalize separators: on Windows path::join uses '\\' but the test
// expects posix-style comparisons.
std::string cache = path::convert_to_slash(std::string_view(config.project.cache_dir));
std::string base = path::convert_to_slash(cache_base);
EXPECT_TRUE(llvm::StringRef(cache).starts_with(base));
EXPECT_TRUE(cache.find("/clice/") != std::string::npos);
}
TEST_CASE(InvalidGlobPattern) {
Config config;
// All-invalid patterns: rule must be dropped entirely, not appended as empty.
config.rules.push_back(ConfigRule{
.patterns = {"**/****.{c,cc}"},
.append = {"-DSHOULD_NOT_APPEAR"},
});
// Mixed valid/invalid: only the invalid pattern is skipped; rule remains.
config.rules.push_back(ConfigRule{
.patterns = {"**/****.{c,cc}", "**/*.cpp"},
.append = {"-DCPP"},
});
config.apply_defaults("");
EXPECT_EQ(config.compiled_rules.size(), 1u);
std::vector<std::string> append, remove;
config.match_rules("/src/foo.cpp", append, remove);
EXPECT_EQ(append.size(), 1u);
EXPECT_EQ(append[0], "-DCPP");
}
TEST_CASE(ConfigPriorityJson) {
// initializationOptions-sourced config should override an on-disk default.
auto from_json =
Config::load_from_json(R"({ "project": { "max_active_file": 42 } })", "/workspace");
EXPECT_TRUE(from_json.has_value());
EXPECT_EQ(from_json->project.max_active_file.value, 42);
// Unset fields still receive defaults.
EXPECT_EQ(*from_json->project.enable_indexing, true);
EXPECT_EQ(from_json->project.stateful_worker_count.value, 2u);
}
TEST_CASE(XdgHashUnique) {
// Different workspace roots must map to different cache dirs,
// same workspace root must map to the same dir (deterministic).
TempDir tmp;
auto cache_base = tmp.path("xdg");
set_env("XDG_CACHE_HOME", cache_base.c_str());
Config a, b, c;
a.apply_defaults("/ws/project-a");
b.apply_defaults("/ws/project-b");
c.apply_defaults("/ws/project-a");
unset_env("XDG_CACHE_HOME");
EXPECT_NE(std::string_view(a.project.cache_dir), std::string_view(b.project.cache_dir));
EXPECT_EQ(std::string_view(a.project.cache_dir), std::string_view(c.project.cache_dir));
}
TEST_CASE(HomeFallback) {
// With XDG_CACHE_HOME unset but HOME set, cache dir should be under $HOME/.cache/clice.
TempDir tmp;
unset_env("XDG_CACHE_HOME");
auto home = tmp.path("home");
// Save prior value so we restore cleanly.
const char* prior = std::getenv("HOME");
std::string prior_home = prior ? prior : "";
set_env("HOME", home.c_str());
Config config;
config.apply_defaults("/some/ws");
if(prior_home.empty())
unset_env("HOME");
else
set_env("HOME", prior_home.c_str());
std::string cache = path::convert_to_slash(std::string_view(config.project.cache_dir));
std::string home_posix = path::convert_to_slash(home);
EXPECT_TRUE(llvm::StringRef(cache).starts_with(home_posix + "/.cache/clice/"));
}
TEST_CASE(WorkspaceCacheFallback) {
// No XDG, no HOME → should fall back to ${workspace}/.clice.
unset_env("XDG_CACHE_HOME");
const char* prior = std::getenv("HOME");
std::string prior_home = prior ? prior : "";
unset_env("HOME");
Config config;
config.apply_defaults("/ws/root");
if(!prior_home.empty())
set_env("HOME", prior_home.c_str());
EXPECT_EQ(path::convert_to_slash(std::string_view(config.project.cache_dir)),
"/ws/root/.clice");
EXPECT_EQ(path::convert_to_slash(std::string_view(config.project.index_dir)),
"/ws/root/.clice/index");
EXPECT_EQ(path::convert_to_slash(std::string_view(config.project.logging_dir)),
"/ws/root/.clice/logs");
}
TEST_CASE(WorkspaceSubstEmpty) {
// Empty workspace_root must not rewrite "${workspace}" into "" and produce
// bogus paths like "/cache" — the placeholder should be left intact.
Config config;
config.project.cache_dir = "${workspace}/cache";
config.apply_defaults("");
EXPECT_EQ(std::string_view(config.project.cache_dir), "${workspace}/cache");
}
TEST_CASE(WorkspaceSubstRepeated) {
// Multiple ${workspace} occurrences in one string all get substituted.
Config config;
config.project.cache_dir = "${workspace}/a/${workspace}/b";
config.apply_defaults("/root");
EXPECT_EQ(std::string_view(config.project.cache_dir), "/root/a//root/b");
}
TEST_CASE(CompilePathsList) {
// compile_commands_paths should substitute ${workspace} on every entry.
Config config;
config.project.compile_commands_paths = {
"${workspace}/build",
"/abs/path/compile_commands.json",
"${workspace}/out",
};
config.apply_defaults("/ws");
EXPECT_EQ(config.project.compile_commands_paths.size(), 3u);
EXPECT_EQ(config.project.compile_commands_paths[0], "/ws/build");
EXPECT_EQ(config.project.compile_commands_paths[1], "/abs/path/compile_commands.json");
EXPECT_EQ(config.project.compile_commands_paths[2], "/ws/out");
}
TEST_CASE(TomlErrorLocated) {
// Malformed TOML (bad table header, missing close-bracket) must return nullopt.
TempDir tmp;
tmp.touch("clice.toml", "[project\nclang_tidy = true\n");
auto result = Config::load(tmp.path("clice.toml"), tmp.root.str());
EXPECT_FALSE(result.has_value());
}
TEST_CASE(WorkspaceMalformedFallback) {
// load_from_workspace must fall back to defaults when clice.toml is malformed,
// not propagate the failure.
TempDir tmp;
tmp.touch("clice.toml", "[project\ninvalid");
auto config = Config::load_from_workspace(tmp.root.str());
// Defaults still applied.
EXPECT_EQ(config.project.stateful_worker_count.value, 2u);
EXPECT_EQ(*config.project.enable_indexing, true);
}
TEST_CASE(RuleOrderLaterRemoveWins) {
// Later rule's `remove` must cancel an earlier rule's matching `append`.
Config config;
config.rules.push_back(ConfigRule{
.patterns = {"**/*.cpp"},
.append = {"-DFOO", "-DBAR"},
});
config.rules.push_back(ConfigRule{
.patterns = {"**/*.cpp"},
.remove = {"-DFOO"},
});
config.apply_defaults("");
std::vector<std::string> append, remove;
config.match_rules("/src/a.cpp", append, remove);
// -DFOO should have been stripped from append; -DBAR remains.
EXPECT_EQ(append.size(), 1u);
EXPECT_EQ(append[0], "-DBAR");
// remove is still forwarded so base CDB flags also get filtered.
EXPECT_EQ(remove.size(), 1u);
EXPECT_EQ(remove[0], "-DFOO");
}
TEST_CASE(RuleOrderLaterAppendWins) {
// Later append comes after earlier append — at compiler level, last wins
// for flags like -O; verify the ordering is preserved.
Config config;
config.rules.push_back(ConfigRule{
.patterns = {"**/*.cpp"},
.append = {"-O2"},
});
config.rules.push_back(ConfigRule{
.patterns = {"**/*.cpp"},
.append = {"-O3"},
});
config.apply_defaults("");
std::vector<std::string> append, remove;
config.match_rules("/src/a.cpp", append, remove);
EXPECT_EQ(append.size(), 2u);
EXPECT_EQ(append[0], "-O2");
EXPECT_EQ(append[1], "-O3");
}
TEST_CASE(InitOptionsOverlayPreservesToml) {
// Mirror the master_server flow: load workspace config from clice.toml first,
// then overlay initializationOptions JSON. Fields absent in the JSON must
// keep their clice.toml values; fields present in the JSON override.
TempDir tmp;
tmp.touch("clice.toml", R"(
[project]
cache_dir = "/from/toml"
clang_tidy = true
max_active_file = 16
[[rules]]
patterns = ["**/*.cpp"]
append = ["-DFROM_TOML"]
)");
auto config = Config::load_from_workspace(tmp.root.str());
EXPECT_EQ(std::string_view(config.project.cache_dir), "/from/toml");
EXPECT_EQ(config.project.clang_tidy.value, true);
EXPECT_EQ(config.project.max_active_file.value, 16);
EXPECT_EQ(config.compiled_rules.size(), 1u);
// Overlay only `max_active_file` via JSON.
auto ov = kota::codec::json::parse(R"({ "project": { "max_active_file": 99 } })", config);
EXPECT_TRUE(ov.has_value());
config.apply_defaults(tmp.root.str());
// Overridden field.
EXPECT_EQ(config.project.max_active_file.value, 99);
// Untouched fields stay at TOML values.
EXPECT_EQ(std::string_view(config.project.cache_dir), "/from/toml");
EXPECT_EQ(config.project.clang_tidy.value, true);
// Rules from clice.toml must survive the overlay.
EXPECT_EQ(config.rules.size(), 1u);
EXPECT_EQ(config.compiled_rules.size(), 1u);
EXPECT_EQ(config.rules[0].append[0], "-DFROM_TOML");
}
TEST_CASE(InitOptionsOverlayRulesReplace) {
// When `rules` is present in the overlay JSON, it replaces the whole array
// (kotatsu deserializes the vector by value). `compiled_rules` must be
// rebuilt after apply_defaults so stale compiled entries don't linger.
TempDir tmp;
tmp.touch("clice.toml", R"(
[[rules]]
patterns = ["**/*.cpp"]
append = ["-DTOML_ONLY"]
)");
auto config = Config::load_from_workspace(tmp.root.str());
EXPECT_EQ(config.compiled_rules.size(), 1u);
auto ov = kota::codec::json::parse(
R"({ "rules": [ { "patterns": ["**/*.cc"], "append": ["-DFROM_JSON"] } ] })",
config);
EXPECT_TRUE(ov.has_value());
config.apply_defaults(tmp.root.str());
EXPECT_EQ(config.rules.size(), 1u);
EXPECT_EQ(config.rules[0].append[0], "-DFROM_JSON");
EXPECT_EQ(config.compiled_rules.size(), 1u);
// Original TOML rule no longer applies.
std::vector<std::string> append, remove;
config.match_rules("/src/x.cpp", append, remove);
EXPECT_TRUE(append.empty());
config.match_rules("/src/x.cc", append, remove);
EXPECT_EQ(append.size(), 1u);
EXPECT_EQ(append[0], "-DFROM_JSON");
}
}; // TEST_SUITE(Config)
} // namespace clice::testing