diff --git a/src/server/compiler.cpp b/src/server/compiler.cpp index febc8a226..349dfeb8d 100644 --- a/src/server/compiler.cpp +++ b/src/server/compiler.cpp @@ -47,8 +47,13 @@ void Compiler::init_compile_graph() { // Lazy dependency resolver: scans a module file on demand to discover imports. auto resolve = [this](std::uint32_t path_id) -> llvm::SmallVector { auto file_path = workspace.path_pool.resolve(path_id); - auto results = - workspace.cdb.lookup(file_path, {.query_toolchain = true, .suppress_logging = true}); + std::vector rule_append, rule_remove; + workspace.config.match_rules(file_path, rule_append, rule_remove); + auto results = workspace.cdb.lookup(file_path, + {.query_toolchain = true, + .suppress_logging = true, + .remove = rule_remove, + .append = rule_append}); if(results.empty()) return {}; @@ -97,7 +102,8 @@ void Compiler::init_compile_graph() { } auto args_hash = llvm::xxh3_64bits(llvm::StringRef(hash_input)); auto pcm_filename = std::format("{}-{:016x}.pcm", safe_module_name, args_hash); - auto pcm_path = path::join(workspace.config.cache_dir, "cache", "pcm", pcm_filename); + auto pcm_path = + path::join(workspace.config.project.cache_dir, "cache", "pcm", pcm_filename); // Check if cached PCM is still valid. if(auto pcm_it = workspace.pcm_cache.find(path_id); pcm_it != workspace.pcm_cache.end()) { @@ -156,7 +162,11 @@ bool Compiler::fill_compile_args(llvm::StringRef path, } // 2. Normal CDB lookup for the file itself. - auto results = workspace.cdb.lookup(path, {.query_toolchain = true}); + // Apply rules from config (append/remove flags based on file patterns). + std::vector rule_append, rule_remove; + workspace.config.match_rules(path, rule_append, rule_remove); + CommandOptions opts{.query_toolchain = true, .remove = rule_remove, .append = rule_append}; + auto results = workspace.cdb.lookup(path, opts); if(!results.empty()) { auto& cmd = results.front(); directory = cmd.resolved.directory.str(); @@ -205,7 +215,13 @@ bool Compiler::fill_header_context_args(llvm::StringRef path, } auto host_path = workspace.path_pool.resolve(ctx_ptr->host_path_id); - auto host_results = workspace.cdb.lookup(host_path, {.query_toolchain = true}); + // Apply rules matching the HEADER path (what the user is editing) on top of + // the host's command — rules are expected to apply uniformly to every file. + std::vector rule_append, rule_remove; + workspace.config.match_rules(path, rule_append, rule_remove); + auto host_results = workspace.cdb.lookup( + host_path, + {.query_toolchain = true, .remove = rule_remove, .append = rule_append}); if(host_results.empty()) { LOG_WARN("fill_header_context_args: host {} has no CDB entry", host_path); return false; @@ -355,7 +371,7 @@ std::optional Compiler::resolve_header_context(std::uint32_t // Hash the preamble and write to cache directory. auto preamble_hash = llvm::xxh3_64bits(llvm::StringRef(preamble)); auto preamble_filename = std::format("{:016x}.h", preamble_hash); - auto preamble_dir = path::join(workspace.config.cache_dir, "header_context"); + auto preamble_dir = path::join(workspace.config.project.cache_dir, "header_context"); auto preamble_path = path::join(preamble_dir, preamble_filename); if(!llvm::sys::fs::exists(preamble_path)) { @@ -438,7 +454,7 @@ kota::task Compiler::ensure_pch(Session& session, auto preamble_hash = llvm::xxh3_64bits(preamble_text); // Deterministic content-addressed PCH path. - auto pch_path = path::join(workspace.config.cache_dir, + auto pch_path = path::join(workspace.config.project.cache_dir, "cache", "pch", std::format("{:016x}.pch", preamble_hash)); diff --git a/src/server/config.cpp b/src/server/config.cpp index d74f9c334..00ee41b50 100644 --- a/src/server/config.cpp +++ b/src/server/config.cpp @@ -1,99 +1,191 @@ #include "server/config.h" #include -#include #include "support/filesystem.h" +#include "support/glob_pattern.h" #include "support/logging.h" +#include "kota/codec/json/json.h" #include "kota/codec/toml.h" +#include "llvm/Support/FileSystem.h" +#include "llvm/Support/Path.h" +#include "llvm/Support/Process.h" +#include "llvm/Support/xxhash.h" namespace clice { /// Replace all occurrences of ${workspace} with the workspace root. -static void substitute_workspace(std::string& value, const std::string& workspace_root) { +/// No-op when workspace_root is empty, to avoid producing paths like "/cache" +/// from "${workspace}/cache". +static void substitute_workspace(std::string& value, llvm::StringRef workspace_root) { + if(workspace_root.empty()) + return; constexpr std::string_view placeholder = "${workspace}"; - std::string::size_type pos = 0; + std::size_t pos = 0; while((pos = value.find(placeholder, pos)) != std::string::npos) { value.replace(pos, placeholder.size(), workspace_root); pos += workspace_root.size(); } } -void CliceConfig::apply_defaults(const std::string& workspace_root) { - auto cpu_count = std::thread::hardware_concurrency(); - if(cpu_count == 0) - cpu_count = 4; - - if(stateful_worker_count == 0) { - stateful_worker_count = 2; - } - if(stateless_worker_count == 0) { - stateless_worker_count = 3; - } - if(worker_memory_limit == 0) { - worker_memory_limit = 4ULL * 1024 * 1024 * 1024; // 4GB default - } - if(cache_dir.empty() && !workspace_root.empty()) { - cache_dir = path::join(workspace_root, ".clice"); +/// Try to resolve the default cache directory using XDG_CACHE_HOME. +/// Returns empty string on failure. +static std::string resolve_xdg_cache_dir(llvm::StringRef workspace_root) { + // Determine base: $XDG_CACHE_HOME or ~/.cache + std::string base; + if(auto xdg = llvm::sys::Process::GetEnv("XDG_CACHE_HOME"); xdg && !xdg->empty()) { + base = std::move(*xdg); + } else if(auto home = llvm::sys::Process::GetEnv("HOME"); home && !home->empty()) { + base = path::join(*home, ".cache"); + } else { + return {}; } - if(index_dir.empty() && !cache_dir.empty()) { - index_dir = path::join(cache_dir, "index"); + // Use a hash of workspace_root to create a unique subdirectory. + auto hash = llvm::xxh3_64bits(workspace_root); + auto dir = path::join(base, "clice", std::format("{:016x}", hash)); + + if(auto ec = llvm::sys::fs::create_directories(dir)) { + LOG_WARN("Failed to create XDG cache directory {}: {}", dir, ec.message()); + return {}; } + return dir; +} - if(logging_dir.empty() && !cache_dir.empty()) { - logging_dir = path::join(cache_dir, "logs"); +void Config::apply_defaults(llvm::StringRef workspace_root) { + auto& p = project; + + if(p.max_active_file == 0) + p.max_active_file = 8; + if(!p.enable_indexing) + p.enable_indexing = true; + if(!p.idle_timeout_ms) + p.idle_timeout_ms = 3000; + + if(p.stateful_worker_count == 0) + p.stateful_worker_count = 2; + if(p.stateless_worker_count == 0) + p.stateless_worker_count = 3; + if(p.worker_memory_limit == 0) + p.worker_memory_limit = 4ULL * 1024 * 1024 * 1024; // 4GB + + if(p.cache_dir.empty() && !workspace_root.empty()) { + p.cache_dir = resolve_xdg_cache_dir(workspace_root); + if(p.cache_dir.empty()) + p.cache_dir = path::join(workspace_root, ".clice"); } + if(p.index_dir.empty() && !p.cache_dir.empty()) + p.index_dir = path::join(p.cache_dir, "index"); + if(p.logging_dir.empty() && !p.cache_dir.empty()) + p.logging_dir = path::join(p.cache_dir, "logs"); + + // Variable substitution on string fields. + substitute_workspace(p.cache_dir, workspace_root); + substitute_workspace(p.index_dir, workspace_root); + substitute_workspace(p.logging_dir, workspace_root); + for(auto& entry: p.compile_commands_paths) + substitute_workspace(entry, workspace_root); + + // Pre-compile glob patterns from rules. + compiled_rules.clear(); + for(auto& rule: rules) { + CompiledRule compiled; + for(auto& pattern_str: rule.patterns) { + auto pat = GlobPattern::create(pattern_str); + if(!pat) { + LOG_WARN("Invalid glob pattern in rule: {}", pattern_str); + continue; + } + compiled.patterns.push_back(std::move(*pat)); + } + // Drop the whole rule if no pattern compiled successfully — otherwise the + // append/remove flags would be silently attached to a rule that can never match. + if(compiled.patterns.empty()) { + if(!rule.patterns.empty()) + LOG_WARN("Rule dropped: all glob patterns failed to compile"); + continue; + } + compiled.append.assign(rule.append.begin(), rule.append.end()); + compiled.remove.assign(rule.remove.begin(), rule.remove.end()); + compiled_rules.push_back(std::move(compiled)); + } +} - // Apply variable substitution to string fields - substitute_workspace(compile_commands_path, workspace_root); - substitute_workspace(cache_dir, workspace_root); - substitute_workspace(index_dir, workspace_root); - substitute_workspace(logging_dir, workspace_root); +void Config::match_rules(llvm::StringRef file_path, + std::vector& append, + std::vector& remove) const { + // Rules are processed in declaration order so that a later rule can + // override an earlier one. Specifically, when a later rule removes + // an argument, we also strip any string-equal entry already added + // to `append` by an earlier matching rule — otherwise the append + // would silently survive (lookup applies removes to the base flags + // only, not to entries contributed via `append`). + for(auto& rule: compiled_rules) { + bool matched = + std::ranges::any_of(rule.patterns, [&](auto& pat) { return pat.match(file_path); }); + if(!matched) + continue; + + for(auto& r: rule.remove) { + std::erase(append, r); + remove.push_back(r); + } + append.insert(append.end(), rule.append.begin(), rule.append.end()); + } } -std::optional CliceConfig::load(const std::string& path, - const std::string& workspace_root) { +std::optional Config::load(llvm::StringRef path, llvm::StringRef workspace_root) { auto content = fs::read(path); - if(!content) { + if(!content) return std::nullopt; - } - auto result = kota::codec::toml::parse(*content); + auto result = kota::codec::toml::parse(*content); if(!result) { - LOG_WARN("Failed to parse config file {}", path); + LOG_ERROR("Invalid clice.toml {}: {}", path, result.error().to_string()); return std::nullopt; } auto config = std::move(*result); config.apply_defaults(workspace_root); - LOG_INFO("Loaded config from {}", path); return config; } -CliceConfig CliceConfig::load_from_workspace(const std::string& workspace_root) { +std::optional Config::load_from_json(llvm::StringRef json, llvm::StringRef workspace_root) { + auto result = kota::codec::json::from_json(json); + if(!result) { + LOG_WARN("Failed to parse initializationOptions JSON: {}", result.error().message()); + return std::nullopt; + } + + auto config = std::move(*result); + config.apply_defaults(workspace_root); + LOG_INFO("Loaded config from initializationOptions"); + return config; +} + +Config Config::load_from_workspace(llvm::StringRef workspace_root) { if(!workspace_root.empty()) { - // Try standard config file locations for(auto* name: {"clice.toml", ".clice/config.toml"}) { auto config_path = path::join(workspace_root, name); - if(llvm::sys::fs::exists(config_path)) { - auto config = load(config_path, workspace_root); - if(config) - return std::move(*config); - } + if(!llvm::sys::fs::exists(config_path)) + continue; + if(auto config = load(config_path, workspace_root)) + return std::move(*config); + // Present but malformed: fall through to defaults, but surface + // the situation clearly so users know their config wasn't applied. + LOG_WARN("Falling back to default configuration because {} is invalid", config_path); } } - // No config file found; use defaults - CliceConfig config; + Config config; config.apply_defaults(workspace_root); LOG_INFO( "No clice.toml found, using default configuration " "(stateful={}, stateless={}, memory_limit={}MB)", - config.stateful_worker_count, - config.stateless_worker_count, - config.worker_memory_limit / (1024 * 1024)); + config.project.stateful_worker_count.value, + config.project.stateless_worker_count.value, + config.project.worker_memory_limit.value / (1024 * 1024)); return config; } diff --git a/src/server/config.h b/src/server/config.h index 0e4df490a..6d398491c 100644 --- a/src/server/config.h +++ b/src/server/config.h @@ -3,44 +3,77 @@ #include #include #include +#include + +#include "support/glob_pattern.h" + +#include "kota/meta/annotation.h" +#include "llvm/ADT/StringRef.h" namespace clice { -/// Configuration for the clice LSP server, loadable from clice.toml. -struct CliceConfig { - // Worker configuration (0 = auto-detect from system resources) - std::uint32_t stateful_worker_count = 0; - std::uint32_t stateless_worker_count = 0; - std::uint64_t worker_memory_limit = 0; // bytes; 0 = auto +using kota::meta::defaulted; + +/// A file-pattern rule that appends/removes compilation flags. +/// Corresponds to `[[rules]]` in clice.toml. +struct ConfigRule { + defaulted> patterns; + defaulted> append; + defaulted> remove; +}; + +/// Corresponds to the `[project]` section in clice.toml. +struct ProjectConfig { + defaulted clang_tidy = {}; + defaulted max_active_file = {}; + + defaulted cache_dir; + defaulted index_dir; + defaulted logging_dir; - // Compilation database path (empty = auto-detect) - std::string compile_commands_path; + defaulted> compile_commands_paths; - // Cache directory (empty = default: /.clice/) - std::string cache_dir; + std::optional enable_indexing; + std::optional idle_timeout_ms; - // Index storage directory (default: /index/) - std::string index_dir; + defaulted stateful_worker_count = {}; + defaulted stateless_worker_count = {}; + defaulted worker_memory_limit = {}; +}; + +struct CompiledRule { + std::vector patterns; + std::vector append; + std::vector remove; +}; - // Logging directory (default: /logs/) - std::string logging_dir; +/// Configuration for the clice LSP server, loadable from clice.toml +/// or passed via LSP initializationOptions. +struct Config { + defaulted project; - // Background indexing - bool enable_indexing = true; - int idle_timeout_ms = 3000; + defaulted> rules; + + kota::meta::annotation, kota::meta::attrs::skip> compiled_rules; /// Compute default values for any field left at its zero/empty sentinel. - void apply_defaults(const std::string& workspace_root); + void apply_defaults(llvm::StringRef workspace_root); + + /// Collect append/remove flags from all rules whose patterns match `path`. + void match_rules(llvm::StringRef path, + std::vector& append, + std::vector& remove) const; /// Try to load configuration from a TOML file. - /// Performs ${workspace} variable substitution in string fields. - /// Returns std::nullopt if the file does not exist or cannot be parsed. - static std::optional load(const std::string& path, - const std::string& workspace_root); + static std::optional load(llvm::StringRef path, llvm::StringRef workspace_root); + + /// Try to load configuration from a JSON string (e.g. initializationOptions). + static std::optional load_from_json(llvm::StringRef json, + llvm::StringRef workspace_root); /// Load config from the workspace, trying standard locations. /// Returns a default config (with apply_defaults) if no file is found. - static CliceConfig load_from_workspace(const std::string& workspace_root); + static Config load_from_workspace(llvm::StringRef workspace_root); }; } // namespace clice diff --git a/src/server/indexer.cpp b/src/server/indexer.cpp index 34a93cdd6..a192e06ba 100644 --- a/src/server/indexer.cpp +++ b/src/server/indexer.cpp @@ -625,14 +625,14 @@ void Indexer::enqueue(std::uint32_t server_path_id) { } void Indexer::schedule() { - if(!workspace.config.enable_indexing || indexing_active || indexing_scheduled) + if(!*workspace.config.project.enable_indexing || indexing_active || indexing_scheduled) return; indexing_scheduled = true; if(!index_idle_timer) { index_idle_timer = std::make_shared(kota::timer::create(loop)); } - index_idle_timer->start(std::chrono::milliseconds(workspace.config.idle_timeout_ms)); + index_idle_timer->start(std::chrono::milliseconds(*workspace.config.project.idle_timeout_ms)); loop.schedule(run_background_indexing()); } @@ -690,7 +690,7 @@ kota::task<> Indexer::run_background_indexing() { indexing_active = false; LOG_INFO("Background indexing complete: {} files processed", processed); - save(workspace.config.index_dir); + save(workspace.config.project.index_dir); } } // namespace clice diff --git a/src/server/master_server.cpp b/src/server/master_server.cpp index 4512ba63c..790b258f0 100644 --- a/src/server/master_server.cpp +++ b/src/server/master_server.cpp @@ -60,46 +60,65 @@ 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; + 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: {}", - workspace.config.compile_commands_path); + LOG_WARN("Configured compile_commands_path not found: {}", configured); } } + // Auto-scan: workspace root + all immediate subdirectories. 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"); + 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); - break; + 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& append, + std::vector& 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(*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(); + } + + auto& cfg = workspace.config.project; - if(!workspace.config.logging_dir.empty()) { + 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<> { diff --git a/src/server/master_server.h b/src/server/master_server.h index 901bf9cbb..250be8d94 100644 --- a/src/server/master_server.h +++ b/src/server/master_server.h @@ -71,6 +71,7 @@ class MasterServer { std::string self_path; std::string workspace_root; std::string session_log_dir; + std::string init_options_json; ///< Raw JSON from initializationOptions, consumed once. kota::task<> load_workspace(); diff --git a/src/server/workspace.cpp b/src/server/workspace.cpp index f30d598dd..3781b7846 100644 --- a/src/server/workspace.cpp +++ b/src/server/workspace.cpp @@ -183,10 +183,10 @@ struct CacheData { } // namespace void Workspace::load_cache() { - if(config.cache_dir.empty()) + if(config.project.cache_dir.empty()) return; - auto cache_path = path::join(config.cache_dir, "cache", "cache.json"); + auto cache_path = path::join(config.project.cache_dir, "cache", "cache.json"); auto content = fs::read(cache_path); if(!content) { LOG_DEBUG("No cache.json found at {}", cache_path); @@ -218,7 +218,7 @@ void Workspace::load_cache() { }; for(auto& entry: data.pch) { - auto pch_path = path::join(config.cache_dir, "cache", "pch", entry.filename); + auto pch_path = path::join(config.project.cache_dir, "cache", "pch", entry.filename); auto source = resolve(entry.source_file); if(!llvm::sys::fs::exists(pch_path) || source.empty()) continue; @@ -234,7 +234,7 @@ void Workspace::load_cache() { } for(auto& entry: data.pcm) { - auto pcm_path = path::join(config.cache_dir, "cache", "pcm", entry.filename); + auto pcm_path = path::join(config.project.cache_dir, "cache", "pcm", entry.filename); auto source = resolve(entry.source_file); if(!llvm::sys::fs::exists(pcm_path) || source.empty()) continue; @@ -252,7 +252,7 @@ void Workspace::load_cache() { } void Workspace::save_cache() { - if(config.cache_dir.empty()) + if(config.project.cache_dir.empty()) return; CacheData data; @@ -306,7 +306,7 @@ void Workspace::save_cache() { return; } - auto cache_path = path::join(config.cache_dir, "cache", "cache.json"); + auto cache_path = path::join(config.project.cache_dir, "cache", "cache.json"); auto tmp_path = cache_path + ".tmp"; auto write_result = fs::write(tmp_path, *json_str); if(!write_result) { @@ -321,14 +321,14 @@ void Workspace::save_cache() { } void Workspace::cleanup_cache(int max_age_days) { - if(config.cache_dir.empty()) + if(config.project.cache_dir.empty()) return; auto now = std::chrono::system_clock::now(); auto max_age = std::chrono::hours(max_age_days * 24); for(auto* subdir: {"cache/pch", "cache/pcm"}) { - auto dir = path::join(config.cache_dir, subdir); + auto dir = path::join(config.project.cache_dir, subdir); std::error_code ec; for(auto it = llvm::sys::fs::directory_iterator(dir, ec); !ec && it != llvm::sys::fs::directory_iterator(); diff --git a/src/server/workspace.h b/src/server/workspace.h index 569d27e5a..75a89ed28 100644 --- a/src/server/workspace.h +++ b/src/server/workspace.h @@ -170,7 +170,7 @@ struct PCMState { /// - didSave (on_file_saved: rescan disk, cascade invalidation) /// - Background index (merge TUIndex results from stateless workers) struct Workspace { - CliceConfig config; + Config config; CompilationDatabase cdb; PathPool path_pool; diff --git a/src/support/glob_pattern.cpp b/src/support/glob_pattern.cpp index 2048d24fa..780943f25 100644 --- a/src/support/glob_pattern.cpp +++ b/src/support/glob_pattern.cpp @@ -289,7 +289,7 @@ std::expected return pat; } -bool GlobPattern::match(llvm::StringRef str) { +bool GlobPattern::match(llvm::StringRef str) const { if(!str.consume_front(prefix)) { return false; } diff --git a/src/support/glob_pattern.h b/src/support/glob_pattern.h index 29a6719e9..db9961894 100644 --- a/src/support/glob_pattern.h +++ b/src/support/glob_pattern.h @@ -54,7 +54,7 @@ class GlobPattern { } /// \returns \p true if \p str matches this glob pattern - bool match(llvm::StringRef s); + bool match(llvm::StringRef s) const; private: /// GlobPattern is seperated into `Prefix + SubGlobPattern` diff --git a/src/syntax/dependency_graph.cpp b/src/syntax/dependency_graph.cpp index a4a834beb..f17b91b34 100644 --- a/src/syntax/dependency_graph.cpp +++ b/src/syntax/dependency_graph.cpp @@ -256,7 +256,8 @@ kota::task<> scan_impl(CompilationDatabase& cdb, DependencyGraph& graph, ScanReport& report, ScanCache* ext_cache, - kota::event_loop& loop) { + kota::event_loop& loop, + const RuleMatcher& rule_matcher) { auto start_time = std::chrono::steady_clock::now(); // Reuse context groups and configs from cache when available (warm runs). @@ -355,9 +356,19 @@ kota::task<> scan_impl(CompilationDatabase& cdb, std::uint32_t config_id = next_config_id++; context_to_config_id[context] = config_id; auto representative_path = path_pool.resolve(file_ids[0]); + + // Apply per-file rules so that `[[rules]]`-modified -I/-isystem/-std + // flags are reflected in the search config used by the scan. + // Rules are applied to the representative file and assumed to hold + // for the whole context group (same CompilationInfo). + std::vector rule_append, rule_remove; + if(rule_matcher) + rule_matcher(representative_path, rule_append, rule_remove); + auto t0 = std::chrono::steady_clock::now(); - configs[config_id] = - cdb.lookup_search_config(representative_path, {.query_toolchain = true}); + configs[config_id] = cdb.lookup_search_config( + representative_path, + {.query_toolchain = true, .remove = rule_remove, .append = rule_append}); auto t1 = std::chrono::steady_clock::now(); lookup_us += std::chrono::duration_cast(t1 - t0).count(); } @@ -819,14 +830,15 @@ kota::task<> scan_impl(CompilationDatabase& cdb, ScanReport scan_dependency_graph(CompilationDatabase& cdb, PathPool& path_pool, DependencyGraph& graph, - ScanCache* cache) { + ScanCache* cache, + const RuleMatcher& rule_matcher) { ScanReport report; if(cdb.get_entries().empty()) { return report; } kota::event_loop loop; - loop.schedule(scan_impl(cdb, path_pool, graph, report, cache, loop)); + loop.schedule(scan_impl(cdb, path_pool, graph, report, cache, loop, rule_matcher)); loop.run(); return report; } diff --git a/src/syntax/dependency_graph.h b/src/syntax/dependency_graph.h index 97e1d6bf8..7e8106936 100644 --- a/src/syntax/dependency_graph.h +++ b/src/syntax/dependency_graph.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -253,6 +254,12 @@ struct ScanCache { std::vector initial_wave; }; +/// Callback for per-file rule-based flag modification. Given a file path, +/// populates `append`/`remove` with rule-configured arguments so they can be +/// layered on top of the CDB command when extracting the search config. +using RuleMatcher = std::function< + void(llvm::StringRef path, std::vector& append, std::vector& remove)>; + /// Run the wavefront BFS scan over all files in the compilation database. /// Internally creates a local event loop for async I/O (file reads via worker /// thread pool, stat calls via libuv). Blocks until the scan is complete. @@ -261,9 +268,14 @@ struct ScanCache { /// avoids repeated readdir() and include-resolution work across /// successive calls. PathPool must NOT be reset between calls /// when a persistent cache is used (path_id values must remain stable). +/// @param rule_matcher Optional callback applied per context group so that +/// `[[rules]]`-modified include/std flags are reflected in the +/// dependency graph (otherwise rule-affected files would have +/// stale resolution). ScanReport scan_dependency_graph(CompilationDatabase& cdb, PathPool& path_pool, DependencyGraph& graph, - ScanCache* cache = nullptr); + ScanCache* cache = nullptr, + const RuleMatcher& rule_matcher = {}); } // namespace clice diff --git a/tests/conftest.py b/tests/conftest.py index 45fa9bdd2..4935e82fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -109,7 +109,9 @@ async def client( await c.start_io(*cmd) if workspace is not None: - await c.initialize(workspace) + init_options_marker = request.node.get_closest_marker("init_options") + init_options = init_options_marker.args[0] if init_options_marker else None + await c.initialize(workspace, initialization_options=init_options) yield c @@ -239,6 +241,15 @@ def _entry(directory: Path, source: Path, extra_args: list[str] | None = None): dl_dir, [_entry(dl_dir, dl_main, [f"-I{dl_dir.as_posix()}", "-std=c++23"])] ) + # config_rules_toml / config_rules_no_config — rules tests must start + # from a CDB that does NOT include the flag the rule will append, so the + # rule's effect is observable through diagnostics. + for name in ("config_rules_toml", "config_rules_no_config"): + cr_dir = data_dir / name + cr_main = cr_dir / "main.cpp" + if cr_main.exists(): + _write(cr_dir, [_entry(cr_dir, cr_main)]) + # pch_test pt_dir = data_dir / "pch_test" if pt_dir.exists(): diff --git a/tests/data/config_rules_no_config/main.cpp b/tests/data/config_rules_no_config/main.cpp new file mode 100644 index 000000000..daf576193 --- /dev/null +++ b/tests/data/config_rules_no_config/main.cpp @@ -0,0 +1,7 @@ +int value() { + return FROM_INIT; +} + +int main() { + return value(); +} diff --git a/tests/data/config_rules_toml/clice.toml b/tests/data/config_rules_toml/clice.toml new file mode 100644 index 000000000..3f06966d9 --- /dev/null +++ b/tests/data/config_rules_toml/clice.toml @@ -0,0 +1,3 @@ +[[rules]] +patterns = ["**/*.cpp"] +append = ["-DFROM_TOML"] diff --git a/tests/data/config_rules_toml/main.cpp b/tests/data/config_rules_toml/main.cpp new file mode 100644 index 000000000..a7d338026 --- /dev/null +++ b/tests/data/config_rules_toml/main.cpp @@ -0,0 +1,7 @@ +int value() { + return FROM_TOML; +} + +int main() { + return value(); +} diff --git a/tests/integration/compilation/test_persistent_cache.py b/tests/integration/compilation/test_persistent_cache.py index a450cae03..fb7be2f7d 100644 --- a/tests/integration/compilation/test_persistent_cache.py +++ b/tests/integration/compilation/test_persistent_cache.py @@ -24,9 +24,17 @@ from tests.integration.utils.assertions import assert_clean_compile +def _pin_cache_to_workspace(tmp_path): + """Write a clice.toml that pins cache_dir to /.clice/.""" + (tmp_path / "clice.toml").write_text( + '[project]\ncache_dir = "${workspace}/.clice"\n' + ) + + async def test_pch_written_to_cache_dir(client, tmp_path): """After opening a file with #include, a .pch file should appear in .clice/cache/pch/ with a hex-hash filename.""" + _pin_cache_to_workspace(tmp_path) (tmp_path / "header.h").write_text("#pragma once\nstruct Foo { int x; };\n") (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { Foo f; return f.x; }\n' @@ -48,6 +56,7 @@ async def test_pch_written_to_cache_dir(client, tmp_path): async def test_cache_json_persisted(client, tmp_path): """After a PCH build, cache.json should be written with the entry.""" + _pin_cache_to_workspace(tmp_path) (tmp_path / "header.h").write_text("#pragma once\nint global_val = 42;\n") (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { return global_val; }\n' @@ -74,6 +83,7 @@ async def test_cache_json_persisted(client, tmp_path): async def test_pch_reused_on_close_reopen(client, tmp_path): """Closing and reopening a file within the same session should reuse the cached PCH — no additional .pch files should be created.""" + _pin_cache_to_workspace(tmp_path) (tmp_path / "header.h").write_text("#pragma once\nstruct Bar { int y; };\n") (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { Bar b; return b.y; }\n' @@ -108,6 +118,7 @@ async def test_pch_reused_on_close_reopen(client, tmp_path): async def test_pch_survives_server_restart(executable, tmp_path): """PCH cache should survive a full server restart — cache.json is loaded on startup and the existing .pch file is reused.""" + _pin_cache_to_workspace(tmp_path) (tmp_path / "header.h").write_text("#pragma once\nstruct Baz { int z; };\n") (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { Baz b; return b.z; }\n' @@ -150,6 +161,7 @@ async def test_pch_survives_server_restart(executable, tmp_path): async def test_shared_preamble_shares_pch(client, tmp_path): """Two files with identical preambles should share the same PCH file (content-addressed by preamble hash).""" + _pin_cache_to_workspace(tmp_path) (tmp_path / "header.h").write_text("#pragma once\nint shared_val = 1;\n") (tmp_path / "a.cpp").write_text( '#include "header.h"\nint fa() { return shared_val; }\n' @@ -176,6 +188,7 @@ async def test_shared_preamble_shares_pch(client, tmp_path): async def test_different_preamble_different_pch(client, tmp_path): """Files with different preambles should produce different PCH files.""" + _pin_cache_to_workspace(tmp_path) (tmp_path / "a.h").write_text("#pragma once\nint val_a = 1;\n") (tmp_path / "b.h").write_text("#pragma once\nint val_b = 2;\n") (tmp_path / "a.cpp").write_text('#include "a.h"\nint fa() { return val_a; }\n') @@ -199,6 +212,7 @@ async def test_different_preamble_different_pch(client, tmp_path): async def test_pch_rebuilt_on_header_change(client, tmp_path): """When a preamble header changes, a new PCH should be built (different hash → different filename). The old one remains for cleanup.""" + _pin_cache_to_workspace(tmp_path) (tmp_path / "header.h").write_text("#pragma once\nstruct V1 { int a; };\n") (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { V1 v; return v.a; }\n' @@ -240,6 +254,7 @@ async def test_pch_rebuilt_on_header_change(client, tmp_path): async def test_no_tmp_files_after_build(client, tmp_path): """After a successful PCH build, no .tmp files should remain in the cache dir.""" + _pin_cache_to_workspace(tmp_path) (tmp_path / "header.h").write_text("#pragma once\nint val = 1;\n") (tmp_path / "main.cpp").write_text( '#include "header.h"\nint main() { return val; }\n' @@ -265,6 +280,7 @@ async def test_no_tmp_files_after_build(client, tmp_path): async def test_cache_dirs_created_on_startup(client, tmp_path): """The .clice/cache/pch/ and .clice/cache/pcm/ directories should be created when the server initializes a workspace.""" + _pin_cache_to_workspace(tmp_path) (tmp_path / "main.cpp").write_text("int main() { return 0; }\n") write_cdb(tmp_path, ["main.cpp"]) await client.initialize(tmp_path) diff --git a/tests/integration/lifecycle/test_config.py b/tests/integration/lifecycle/test_config.py new file mode 100644 index 000000000..a6abaf73f --- /dev/null +++ b/tests/integration/lifecycle/test_config.py @@ -0,0 +1,68 @@ +"""Integration tests for clice configuration (clice.toml + initializationOptions). + +Each workspace's main.cpp references a macro that is only defined when the +rule's `-D=...` is applied. When rules are applied, compilation is +clean; otherwise an undeclared-identifier diagnostic surfaces. +""" + +import pytest + +from tests.integration.utils.assertions import ( + assert_clean_compile, + assert_has_errors, + get_errors, +) + + +@pytest.mark.workspace("config_rules_no_config") +async def test_baseline_without_rules(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + assert_has_errors(client, uri, "Expected diagnostics without any rules applied") + errors = get_errors(client.diagnostics[uri]) + assert any("FROM_INIT" in (d.message or "") for d in errors), ( + f"Expected a diagnostic referencing FROM_INIT, got: {errors}" + ) + + +@pytest.mark.workspace("config_rules_toml") +async def test_rules_from_toml(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + assert_clean_compile(client, uri) + + symbols = await client.document_symbols(uri) + assert symbols, "Expected document symbols for value()/main()" + hover = await client.hover_at(uri, line=4, character=4) # on 'main' + assert hover is not None + + +@pytest.mark.workspace("config_rules_no_config") +@pytest.mark.init_options( + {"rules": [{"patterns": ["**/*.cpp"], "append": ["-DFROM_INIT=1"]}]} +) +async def test_rules_from_init_options(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + assert_clean_compile(client, uri) + + +@pytest.mark.workspace("config_rules_toml") +@pytest.mark.init_options( + {"rules": [{"patterns": ["**/*.cpp"], "append": ["-DUNRELATED"]}]} +) +async def test_init_options_replaces_toml_rules(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + assert_has_errors( + client, uri, "initializationOptions should have overridden clice.toml rules" + ) + errors = get_errors(client.diagnostics[uri]) + assert any("FROM_TOML" in (d.message or "") for d in errors), ( + f"Expected FROM_TOML diagnostic after override, got: {errors}" + ) + + +@pytest.mark.workspace("config_rules_no_config") +@pytest.mark.init_options( + {"rules": [{"patterns": ["**/does_not_match.cpp"], "append": ["-DFROM_INIT=1"]}]} +) +async def test_rules_pattern_mismatch(client, workspace): + uri, _ = await client.open_and_wait(workspace / "main.cpp") + assert_has_errors(client, uri, "Rule pattern should not have matched main.cpp") diff --git a/tests/integration/utils/client.py b/tests/integration/utils/client.py index 009a3f2f5..7b4319c04 100644 --- a/tests/integration/utils/client.py +++ b/tests/integration/utils/client.py @@ -86,16 +86,20 @@ def path_to_uri(self, filepath: Path) -> str: # ── Lifecycle ──────────────────────────────────────────────────── - async def initialize(self, workspace: Path) -> InitializeResult: - result = await self.initialize_async( - InitializeParams( - capabilities=ClientCapabilities(), - root_uri=workspace.as_uri(), - workspace_folders=[ - WorkspaceFolder(uri=workspace.as_uri(), name="test") - ], - ) + async def initialize( + self, + workspace: Path, + *, + initialization_options: dict | None = None, + ) -> InitializeResult: + params = InitializeParams( + capabilities=ClientCapabilities(), + root_uri=workspace.as_uri(), + workspace_folders=[WorkspaceFolder(uri=workspace.as_uri(), name="test")], ) + if initialization_options is not None: + params.initialization_options = initialization_options + result = await self.initialize_async(params) self.initialized(InitializedParams()) self.init_result = result return result diff --git a/tests/pytest.ini b/tests/pytest.ini index 50cc7b46e..db904db7c 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -1,3 +1,5 @@ [pytest] asyncio_mode = auto -markers = workspace +markers = + workspace + init_options diff --git a/tests/unit/server/config_tests.cpp b/tests/unit/server/config_tests.cpp new file mode 100644 index 000000000..f29f81017 --- /dev/null +++ b/tests/unit/server/config_tests.cpp @@ -0,0 +1,501 @@ +#include + +#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.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(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(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(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(""); + 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(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 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 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 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_EQ(config.project.stateless_worker_count.value, 3u); + 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 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 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 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 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