diff --git a/src/command/command.cpp b/src/command/command.cpp index 210fa8fe5..a7197f57d 100644 --- a/src/command/command.cpp +++ b/src/command/command.cpp @@ -650,6 +650,11 @@ std::uint32_t CompilationDatabase::intern_path(llvm::StringRef path) { return paths.intern(path); } +bool CompilationDatabase::has_entry(llvm::StringRef file) { + auto path_id = paths.intern(file); + return !find_entries(path_id).empty(); +} + llvm::ArrayRef CompilationDatabase::get_entries() const { return entries; } diff --git a/src/command/command.h b/src/command/command.h index cfa279548..e51335ab7 100644 --- a/src/command/command.h +++ b/src/command/command.h @@ -191,6 +191,10 @@ class CompilationDatabase { /// Intern a file path and return its path_id. std::uint32_t intern_path(llvm::StringRef path); + /// Check if a file has an explicit entry in the compilation database + /// (as opposed to a synthesized default). + bool has_entry(llvm::StringRef file); + /// All compilation entries (sorted by path_id). llvm::ArrayRef get_entries() const; diff --git a/src/server/master_server.cpp b/src/server/master_server.cpp index d5837e2ac..cdfd01a7d 100644 --- a/src/server/master_server.cpp +++ b/src/server/master_server.cpp @@ -411,6 +411,9 @@ et::task<> MasterServer::load_workspace() { auto report = scan_dependency_graph(cdb, path_pool, dependency_graph); + // Build reverse include map so headers can find their host source files. + dependency_graph.build_reverse_map(); + auto unresolved = report.includes_found - report.includes_resolved; double accuracy = report.includes_found > 0 @@ -562,20 +565,247 @@ et::task<> MasterServer::load_workspace() { LOG_INFO("CompileGraph initialized with {} module(s)", path_to_module.size()); } +std::optional + MasterServer::resolve_header_context(std::uint32_t header_path_id) { + // Find source files that transitively include this header. + auto hosts = dependency_graph.find_host_sources(header_path_id); + if(hosts.empty()) { + LOG_DEBUG("resolve_header_context: no host sources for path_id={}", header_path_id); + return std::nullopt; + } + + // If there's an active context override, prefer that host. + std::uint32_t host_path_id = 0; + std::vector chain; + auto active_it = active_contexts.find(header_path_id); + if(active_it != active_contexts.end()) { + auto preferred = active_it->second; + auto preferred_path = path_pool.resolve(preferred); + auto results = cdb.lookup(preferred_path, {.suppress_logging = true}); + if(!results.empty()) { + auto c = dependency_graph.find_include_chain(preferred, header_path_id); + if(!c.empty()) { + host_path_id = preferred; + chain = std::move(c); + } + } + } + + // Fall back to the first available host that has a CDB entry. + if(chain.empty()) { + for(auto candidate: hosts) { + auto candidate_path = path_pool.resolve(candidate); + auto results = cdb.lookup(candidate_path, {.suppress_logging = true}); + if(results.empty()) + continue; + auto c = dependency_graph.find_include_chain(candidate, header_path_id); + if(c.empty()) + continue; + host_path_id = candidate; + chain = std::move(c); + break; + } + } + + if(chain.empty()) { + LOG_DEBUG("resolve_header_context: no usable host with include chain for path_id={}", + header_path_id); + return std::nullopt; + } + + // Build preamble text: for each file in the chain except the last (target), + // append all content up to (but not including) the line that includes the + // next file in the chain. + std::string preamble; + for(std::size_t i = 0; i + 1 < chain.size(); ++i) { + auto cur_id = chain[i]; + auto next_id = chain[i + 1]; + + auto cur_path = path_pool.resolve(cur_id); + auto next_path = path_pool.resolve(next_id); + auto next_filename = llvm::sys::path::filename(next_path); + + // Prefer in-memory document text over disk content. + std::string content; + if(auto doc_it = documents.find(cur_id); doc_it != documents.end()) { + content = doc_it->second.text; + } else { + auto buf = llvm::MemoryBuffer::getFile(cur_path); + if(!buf) { + LOG_WARN("resolve_header_context: cannot read {}", cur_path); + return std::nullopt; + } + content = (*buf)->getBuffer().str(); + } + + // Scan line by line for the #include that brings in next_filename. + llvm::StringRef content_ref(content); + std::size_t line_start = 0; + std::size_t include_line_start = std::string::npos; + while(line_start <= content_ref.size()) { + auto newline_pos = content_ref.find('\n', line_start); + auto line_end = + (newline_pos == llvm::StringRef::npos) ? content_ref.size() : newline_pos; + auto line = content_ref.slice(line_start, line_end).trim(); + + if(line.starts_with("#include") || line.starts_with("# include")) { + // Extract the filename from the #include directive. + // Handles: #include "foo.h", #include , # include "foo.h" + auto quote_start = line.find_first_of("\"<"); + auto quote_end = llvm::StringRef::npos; + if(quote_start != llvm::StringRef::npos) { + char close = (line[quote_start] == '"') ? '"' : '>'; + quote_end = line.find(close, quote_start + 1); + } + if(quote_start != llvm::StringRef::npos && quote_end != llvm::StringRef::npos) { + auto included = line.slice(quote_start + 1, quote_end); + auto included_filename = llvm::sys::path::filename(included); + if(included_filename == next_filename) { + include_line_start = line_start; + break; + } + } + } + + line_start = + (newline_pos == llvm::StringRef::npos) ? content_ref.size() + 1 : newline_pos + 1; + } + + // Emit a #line marker then all content before the include line. + preamble += std::format("#line 1 \"{}\"\n", cur_path.str()); + if(include_line_start != std::string::npos) { + preamble += content_ref.substr(0, include_line_start).str(); + } else { + // No matching include line found — emit the whole file to be safe. + LOG_DEBUG("resolve_header_context: include line for {} not found in {}, emitting full", + next_filename, + cur_path); + preamble += content; + } + } + + // 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(config.cache_dir, "header_context"); + auto preamble_path = path::join(preamble_dir, preamble_filename); + + if(!llvm::sys::fs::exists(preamble_path)) { + auto ec = llvm::sys::fs::create_directories(preamble_dir); + if(ec) { + LOG_WARN("resolve_header_context: cannot create dir {}: {}", + preamble_dir, + ec.message()); + return std::nullopt; + } + if(auto result = fs::write(preamble_path, preamble); !result) { + LOG_WARN("resolve_header_context: cannot write preamble {}: {}", + preamble_path, + result.error().message()); + return std::nullopt; + } + LOG_INFO("resolve_header_context: wrote preamble {} for header path_id={}", + preamble_path, + header_path_id); + } + + return HeaderFileContext{host_path_id, preamble_path, preamble_hash}; +} + bool MasterServer::fill_compile_args(llvm::StringRef path, std::string& directory, std::vector& arguments) { + auto path_id = path_pool.intern(path); + + // 1. If the user has set an active header context via switchContext, + // use the host source's CDB entry with file path replaced and preamble injected. + auto active_it = active_contexts.find(path_id); + if(active_it != active_contexts.end()) { + return fill_header_context_args(path, path_id, directory, arguments); + } + + // 2. Normal CDB lookup for the file itself. auto results = cdb.lookup(path, {.query_toolchain = true}); - if(results.empty()) { - LOG_WARN("No CDB entry for {}", path); + if(!results.empty()) { + auto& ctx = results.front(); + directory = ctx.directory.str(); + arguments.clear(); + for(auto* arg: ctx.arguments) { + arguments.emplace_back(arg); + } + return true; + } + + // 3. No CDB entry — try automatic header context resolution. + return fill_header_context_args(path, path_id, directory, arguments); +} + +bool MasterServer::fill_header_context_args(llvm::StringRef path, + std::uint32_t path_id, + std::string& directory, + std::vector& arguments) { + // Use cached context if available; otherwise resolve. + // If an active context override exists, invalidate cache if it points to + // a different host so we re-resolve with the correct one. + const HeaderFileContext* ctx_ptr = nullptr; + auto ctx_it = header_file_contexts.find(path_id); + auto active_it = active_contexts.find(path_id); + if(ctx_it != header_file_contexts.end()) { + if(active_it != active_contexts.end() && ctx_it->second.host_path_id != active_it->second) { + header_file_contexts.erase(ctx_it); + } else { + ctx_ptr = &ctx_it->second; + } + } + if(!ctx_ptr) { + auto resolved = resolve_header_context(path_id); + if(!resolved) { + LOG_WARN("No CDB entry and no header context for {}", path); + return false; + } + header_file_contexts[path_id] = std::move(*resolved); + ctx_ptr = &header_file_contexts[path_id]; + } + + auto host_path = path_pool.resolve(ctx_ptr->host_path_id); + auto host_results = cdb.lookup(host_path, {.query_toolchain = true}); + if(host_results.empty()) { + LOG_WARN("fill_header_context_args: host {} has no CDB entry", host_path); return false; } - auto& ctx = results.front(); - directory = ctx.directory.str(); + + auto& host_ctx = host_results.front(); + directory = host_ctx.directory.str(); arguments.clear(); - for(auto* arg: ctx.arguments) { - arguments.emplace_back(arg); + + // Copy host arguments, replacing the host source file path with the header. + bool replaced = false; + for(auto& arg: host_ctx.arguments) { + if(llvm::StringRef(arg) == host_path) { + arguments.emplace_back(path); + replaced = true; + } else { + arguments.emplace_back(arg); + } + } + if(!replaced) { + LOG_WARN("fill_header_context_args: host path {} not found in arguments, appending header", + host_path); + arguments.emplace_back(path); + } + + // Inject preamble: for cc1 args insert after "-cc1", otherwise after driver. + std::size_t inject_pos = 1; + if(arguments.size() >= 2 && arguments[1] == "-cc1") { + inject_pos = 2; } + arguments.insert(arguments.begin() + inject_pos, ctx_ptr->preamble_path); + arguments.insert(arguments.begin() + inject_pos, "-include"); + + LOG_INFO("fill_compile_args: header context for {} (host={}, preamble={})", + path, + host_path, + ctx_ptr->preamble_path); return true; } @@ -758,7 +988,9 @@ et::task MasterServer::ensure_deps(std::uint32_t path_id, et::task MasterServer::ensure_compiled(std::uint32_t path_id) { auto it = documents.find(path_id); if(it == documents.end()) { - LOG_DEBUG("ensure_compiled: doc not found for path_id={}", path_id); + LOG_WARN("ensure_compiled: doc not found for path_id={} path={}", + path_id, + path_pool.resolve(path_id)); co_return false; } @@ -2018,6 +2250,22 @@ void MasterServer::register_handlers() { } } + // Invalidate header contexts whose host is the saved file. + // Collect entries to erase to avoid modifying the map while iterating. + llvm::SmallVector stale_headers; + for(auto& [hdr_id, hdr_ctx]: header_file_contexts) { + if(hdr_ctx.host_path_id == path_id) + stale_headers.push_back(hdr_id); + } + for(auto hdr_id: stale_headers) { + header_file_contexts.erase(hdr_id); + auto doc_it = documents.find(hdr_id); + if(doc_it != documents.end()) { + doc_it->second.ast_dirty = true; + LOG_DEBUG("didSave: invalidated header context for path_id={}", hdr_id); + } + } + // Trigger background indexing after save. schedule_indexing(); @@ -2515,6 +2763,142 @@ void MasterServer::register_handlers() { co_return serde_raw{"null"}; co_return to_raw(results); }); + + // === clice/ Extension Commands === + + // --- clice/queryContext --- + peer.on_request( + "clice/queryContext", + [this](RequestContext& ctx, const ext::QueryContextParams& params) -> RawResult { + auto path = uri_to_path(params.uri); + auto path_id = path_pool.intern(path); + int offset_val = std::max(0, params.offset.value_or(0)); + constexpr int page_size = 10; + + ext::QueryContextResult result; + + std::vector all_items; + + // For headers: find source files that transitively include this file. + auto hosts = dependency_graph.find_host_sources(path_id); + for(auto host_id: hosts) { + auto host_path = path_pool.resolve(host_id); + auto host_cdb = cdb.lookup(host_path, {.suppress_logging = true}); + if(host_cdb.empty()) + continue; + auto host_uri_opt = lsp::URI::from_file_path(std::string(host_path)); + if(!host_uri_opt) + continue; + ext::ContextItem item; + item.label = llvm::sys::path::filename(host_path).str(); + item.description = std::string(host_path); + item.uri = host_uri_opt->str(); + all_items.push_back(std::move(item)); + } + + // For source files: list distinct CDB entries (e.g. debug/release). + if(hosts.empty()) { + auto entries = cdb.lookup(path, {.suppress_logging = true}); + for(std::size_t i = 0; i < entries.size(); ++i) { + auto& entry = entries[i]; + // Build a description from distinguishing flags. + std::string desc; + for(std::size_t j = 0; j < entry.arguments.size(); ++j) { + llvm::StringRef a(entry.arguments[j]); + if(a.starts_with("-D") || a.starts_with("-O") || a.starts_with("-std=") || + a.starts_with("-g")) { + if(!desc.empty()) + desc += ' '; + desc += entry.arguments[j]; + // Handle split args like "-D" "CONFIG_A" + if((a == "-D" || a == "-O") && j + 1 < entry.arguments.size()) { + desc += entry.arguments[++j]; + } + } + } + if(desc.empty()) + desc = std::format("config #{}", i); + + auto uri_opt = lsp::URI::from_file_path(std::string(path)); + if(!uri_opt) + continue; + ext::ContextItem item; + item.label = desc; + item.description = entry.directory.str(); + item.uri = uri_opt->str(); + all_items.push_back(std::move(item)); + } + } + + result.total = static_cast(all_items.size()); + int end = std::min(offset_val + page_size, static_cast(all_items.size())); + for(int i = offset_val; i < end; ++i) { + result.contexts.push_back(std::move(all_items[i])); + } + co_return to_raw(result); + }); + + // --- clice/currentContext --- + peer.on_request( + "clice/currentContext", + [this](RequestContext& ctx, const ext::CurrentContextParams& params) -> RawResult { + auto path = uri_to_path(params.uri); + auto path_id = path_pool.intern(path); + + ext::CurrentContextResult result; + + auto it = active_contexts.find(path_id); + if(it != active_contexts.end()) { + auto ctx_path = path_pool.resolve(it->second); + auto ctx_uri_opt = lsp::URI::from_file_path(std::string(ctx_path)); + if(ctx_uri_opt) { + ext::ContextItem item; + item.label = llvm::sys::path::filename(ctx_path).str(); + item.description = std::string(ctx_path); + item.uri = ctx_uri_opt->str(); + result.context = std::move(item); + } + } + co_return to_raw(result); + }); + + // --- clice/switchContext --- + peer.on_request( + "clice/switchContext", + [this](RequestContext& ctx, const ext::SwitchContextParams& params) -> RawResult { + auto path = uri_to_path(params.uri); + auto path_id = path_pool.intern(path); + auto context_path = uri_to_path(params.context_uri); + auto context_path_id = path_pool.intern(context_path); + + ext::SwitchContextResult result; + + // Verify the context file has a CDB entry. + auto context_cdb = cdb.lookup(context_path, {.suppress_logging = true}); + if(context_cdb.empty()) { + result.success = false; + co_return to_raw(result); + } + + // Set active context and invalidate cached header context so + // resolve_header_context will pick the new host on next compile. + active_contexts[path_id] = context_path_id; + header_file_contexts.erase(path_id); + + // Also invalidate the PCH and AST deps for the old context so + // they get rebuilt with the new host's preamble. + pch_states.erase(path_id); + ast_deps.erase(path_id); + + // Mark the document as dirty so it gets recompiled. + auto doc_it = documents.find(path_id); + if(doc_it != documents.end()) { + doc_it->second.ast_dirty = true; + } + + result.success = true; + co_return to_raw(result); + }); } } // namespace clice diff --git a/src/server/master_server.h b/src/server/master_server.h index 601cbf58b..50bc094a2 100644 --- a/src/server/master_server.h +++ b/src/server/master_server.h @@ -55,6 +55,13 @@ struct DocumentState { std::shared_ptr compiling; }; +/// Context for compiling a header file that lacks its own CDB entry. +struct HeaderFileContext { + std::uint32_t host_path_id; // Source file acting as host + std::string preamble_path; // Path to generated preamble file on disk + std::uint64_t preamble_hash; // Hash of preamble content for staleness +}; + /// Two-layer staleness snapshot for compilation artifacts (PCH, AST, etc.). /// /// Layer 1 (fast): compare each file's current mtime against build_at. @@ -216,6 +223,14 @@ class MasterServer { /// Per-file dependency snapshots from last successful AST compilation. llvm::DenseMap ast_deps; + /// Header context cache: header_path_id -> context + llvm::DenseMap header_file_contexts; + + /// Active compilation context overrides: path_id -> context_path_id. + /// When a file has an entry here, it is compiled using the context file's + /// compile command (e.g. a header compiled through a specific source file). + llvm::DenseMap active_contexts; + // === Helpers === /// Convert a file:// URI to a local file path. @@ -240,6 +255,18 @@ class MasterServer { std::string& directory, std::vector& arguments); + /// Fill compile arguments using header context (host source's CDB entry + /// with file path replaced and preamble injected). + bool fill_header_context_args(llvm::StringRef path, + std::uint32_t path_id, + std::string& directory, + std::vector& arguments); + + /// Generate a preamble file for compiling a header in context. + /// The preamble contains all code from the host source (and intermediate + /// headers) that comes BEFORE the #include of the target header. + std::optional resolve_header_context(std::uint32_t header_path_id); + /// Build or reuse PCH for a source file. Returns true if PCH is available. et::task ensure_pch(std::uint32_t path_id, llvm::StringRef path, diff --git a/src/server/protocol.h b/src/server/protocol.h index bca3931cc..1e12b7ffa 100644 --- a/src/server/protocol.h +++ b/src/server/protocol.h @@ -155,6 +155,45 @@ struct EvictedParams { } // namespace clice::worker +// === clice/ LSP Extension Types === + +namespace clice::ext { + +struct ContextItem { + std::string label; + std::string description; + std::string uri; +}; + +struct QueryContextParams { + std::string uri; + std::optional offset; +}; + +struct QueryContextResult { + std::vector contexts; + int total; +}; + +struct CurrentContextParams { + std::string uri; +}; + +struct CurrentContextResult { + std::optional context; +}; + +struct SwitchContextParams { + std::string uri; + std::string context_uri; +}; + +struct SwitchContextResult { + bool success; +}; + +} // namespace clice::ext + namespace eventide::ipc::protocol { // === Stateful Requests === diff --git a/src/syntax/dependency_graph.cpp b/src/syntax/dependency_graph.cpp index 05a581fe2..9443dc13f 100644 --- a/src/syntax/dependency_graph.cpp +++ b/src/syntax/dependency_graph.cpp @@ -1,5 +1,6 @@ #include "syntax/dependency_graph.h" +#include #include #include "command/toolchain.h" @@ -101,6 +102,109 @@ std::size_t DependencyGraph::edge_count() const { return count; } +void DependencyGraph::build_reverse_map() { + reverse_includes_.clear(); + for(auto& [key, ids]: includes) { + for(auto flagged_id: ids) { + auto included_id = flagged_id & PATH_ID_MASK; + auto& vec = reverse_includes_[included_id]; + if(llvm::find(vec, key.path_id) == vec.end()) { + vec.push_back(key.path_id); + } + } + } +} + +llvm::ArrayRef DependencyGraph::get_includers(std::uint32_t path_id) const { + auto it = reverse_includes_.find(path_id); + if(it != reverse_includes_.end()) { + return it->second; + } + return {}; +} + +llvm::SmallVector + DependencyGraph::find_host_sources(std::uint32_t header_path_id) const { + llvm::SmallVector result; + llvm::DenseSet visited; + llvm::SmallVector queue; + + queue.push_back(header_path_id); + visited.insert(header_path_id); + + while(!queue.empty()) { + auto current = queue.pop_back_val(); + auto includers = get_includers(current); + if(includers.empty()) { + // No includers: this is a root (source file). + // Exclude the starting header itself. + if(current != header_path_id) { + result.push_back(current); + } + continue; + } + for(auto includer: includers) { + if(visited.insert(includer).second) { + queue.push_back(includer); + } + } + } + + return result; +} + +std::vector DependencyGraph::find_include_chain(std::uint32_t host_path_id, + std::uint32_t target_path_id) const { + if(host_path_id == target_path_id) { + return {host_path_id}; + } + + // BFS: predecessor map for path reconstruction. + llvm::DenseMap prev; + llvm::SmallVector queue; + + prev[host_path_id] = host_path_id; + queue.push_back(host_path_id); + + bool found = false; + while(!queue.empty() && !found) { + llvm::SmallVector next_queue; + for(auto current: queue) { + auto includes_union = get_all_includes(current); + for(auto flagged_id: includes_union) { + auto child = flagged_id & PATH_ID_MASK; + if(prev.find(child) == prev.end()) { + prev[child] = current; + if(child == target_path_id) { + found = true; + break; + } + next_queue.push_back(child); + } + } + if(found) { + break; + } + } + queue = std::move(next_queue); + } + + if(!found) { + return {}; + } + + // Reconstruct path from target back to host. + std::vector chain; + auto node = target_path_id; + while(node != host_path_id) { + chain.push_back(node); + node = prev[node]; + } + chain.push_back(host_path_id); + std::reverse(chain.begin(), chain.end()); + return chain; +} + // ============================================================================ // Wavefront BFS scanner — async implementation // ============================================================================ diff --git a/src/syntax/dependency_graph.h b/src/syntax/dependency_graph.h index 90b6f14fb..97e1d6bf8 100644 --- a/src/syntax/dependency_graph.h +++ b/src/syntax/dependency_graph.h @@ -70,6 +70,24 @@ class DependencyGraph { /// Get the union of includes across all configs for a file. llvm::SmallVector get_all_includes(std::uint32_t path_id) const; + /// Build the reverse include map from the forward includes. + /// Must be called after all set_includes() calls are complete. + void build_reverse_map(); + + /// Get the direct includers of a file (files that directly include path_id). + llvm::ArrayRef get_includers(std::uint32_t path_id) const; + + /// BFS upward through reverse edges to find all source files (roots) + /// that transitively include header_path_id. + /// Source files are those that have no includers (i.e. they are roots in the graph). + llvm::SmallVector find_host_sources(std::uint32_t header_path_id) const; + + /// BFS forward through include edges to find the shortest include chain + /// from host_path_id to target_path_id. + /// Returns [host, intermediate1, ..., target], or empty if no path exists. + std::vector find_include_chain(std::uint32_t host_path_id, + std::uint32_t target_path_id) const; + /// Number of files with include entries. std::size_t file_count() const; @@ -94,6 +112,10 @@ class DependencyGraph { /// Track which files have any include entries (for file_count). llvm::DenseMap> file_configs; + + /// Reverse include map: PathID -> list of PathIDs that directly include it. + /// Populated by build_reverse_map(). + llvm::DenseMap> reverse_includes_; }; /// A (file, search-config) pair used to track per-wave work items. diff --git a/tests/conftest.py b/tests/conftest.py index 33e04334a..ec039d9e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -140,6 +140,10 @@ def open(self, filepath: Path, version: int = 0) -> tuple[str, str]: ) return self._normalize_uri(wire_uri), content + def path_to_uri(self, filepath: Path) -> str: + """Convert a file path to a normalized URI without opening it.""" + return self._normalize_uri(filepath.as_uri()) + async def wait_diagnostics(self, uri: str, timeout: float = 30.0) -> None: """Wait for diagnostics on the given URI.""" uri = self._normalize_uri(uri) @@ -220,6 +224,58 @@ def test_data_dir() -> Path: ] cdb_path.write_text(json.dumps(cdb, indent=2)) + # Generate compile_commands.json for header_context (always regenerate + # because it contains absolute paths). + hc_dir = data_dir / "header_context" + hc_main = hc_dir / "main.cpp" + hc_cdb = hc_dir / "compile_commands.json" + if hc_main.exists(): + cdb = [ + { + "directory": hc_dir.as_posix(), + "file": hc_main.as_posix(), + "arguments": [ + "clang++", + "-std=c++17", + f"-I{hc_dir.as_posix()}", + "-fsyntax-only", + hc_main.as_posix(), + ], + } + ] + hc_cdb.write_text(json.dumps(cdb, indent=2)) + + # Generate compile_commands.json for multi_context (same file, two configs) + mc_dir = data_dir / "multi_context" + mc_main = mc_dir / "main.cpp" + mc_cdb = mc_dir / "compile_commands.json" + if mc_main.exists(): + cdb = [ + { + "directory": mc_dir.as_posix(), + "file": mc_main.as_posix(), + "arguments": [ + "clang++", + "-std=c++17", + "-DCONFIG_A", + "-fsyntax-only", + mc_main.as_posix(), + ], + }, + { + "directory": mc_dir.as_posix(), + "file": mc_main.as_posix(), + "arguments": [ + "clang++", + "-std=c++17", + "-DCONFIG_B", + "-fsyntax-only", + mc_main.as_posix(), + ], + }, + ] + mc_cdb.write_text(json.dumps(cdb, indent=2)) + # Generate compile_commands.json for include_completion ic_dir = data_dir / "include_completion" ic_main = ic_dir / "main.cpp" @@ -240,6 +296,31 @@ def test_data_dir() -> Path: ] ic_cdb.write_text(json.dumps(cdb, indent=2)) + # Generate compile_commands.json for pch_test (always regenerate for + # absolute paths). + pt_dir = data_dir / "pch_test" + pt_cdb = pt_dir / "compile_commands.json" + for src_name in ["main.cpp", "no_includes.cpp"]: + src = pt_dir / src_name + if not src.exists(): + continue + if src_name == "main.cpp": + entries = [] + entries.append( + { + "directory": pt_dir.as_posix(), + "file": src.as_posix(), + "arguments": [ + "clang++", + "-std=c++17", + "-fsyntax-only", + src.as_posix(), + ], + } + ) + if pt_dir.exists(): + pt_cdb.write_text(json.dumps(entries, indent=2)) + return data_dir diff --git a/tests/data/header_context/inner.h b/tests/data/header_context/inner.h new file mode 100644 index 000000000..057aeba21 --- /dev/null +++ b/tests/data/header_context/inner.h @@ -0,0 +1,6 @@ +#pragma once + +// Non self-contained: uses Point from the include chain. +inline Point inner_origin() { + return Point{0, 0}; +} diff --git a/tests/data/header_context/main.cpp b/tests/data/header_context/main.cpp new file mode 100644 index 000000000..d92fd057e --- /dev/null +++ b/tests/data/header_context/main.cpp @@ -0,0 +1,8 @@ +#include "types.h" +#include "utils.h" + +int main() { + Point p{3, 4}; + int d = calc(p); + return d; +} diff --git a/tests/data/header_context/types.h b/tests/data/header_context/types.h new file mode 100644 index 000000000..ff878d349 --- /dev/null +++ b/tests/data/header_context/types.h @@ -0,0 +1,12 @@ +#pragma once + +struct Point { + int x; + int y; +}; + +inline int distance(Point a, Point b) { + int dx = a.x - b.x; + int dy = a.y - b.y; + return dx * dx + dy * dy; +} diff --git a/tests/data/header_context/utils.h b/tests/data/header_context/utils.h new file mode 100644 index 000000000..43832bba0 --- /dev/null +++ b/tests/data/header_context/utils.h @@ -0,0 +1,9 @@ +// Non self-contained header: uses Point from types.h without including it. +// Depends on the including source file to provide the types.h include. +#pragma once + +#include "inner.h" + +inline int calc(Point p) { + return distance(p, inner_origin()); +} diff --git a/tests/data/multi_context/main.cpp b/tests/data/multi_context/main.cpp new file mode 100644 index 000000000..46a620213 --- /dev/null +++ b/tests/data/multi_context/main.cpp @@ -0,0 +1,15 @@ +#ifdef CONFIG_A +int config_value() { + return 1; +} +#endif + +#ifdef CONFIG_B +int config_value() { + return 2; +} +#endif + +int main() { + return config_value(); +} diff --git a/tests/integration/test_header_context.py b/tests/integration/test_header_context.py new file mode 100644 index 000000000..416827b82 --- /dev/null +++ b/tests/integration/test_header_context.py @@ -0,0 +1,263 @@ +"""Integration tests for header context LSP extension commands. + +Tests the clice/queryContext, clice/currentContext, and clice/switchContext +extension commands that allow switching the compilation context for header files. + +utils.h uses Point without including types.h itself -- it depends on +main.cpp to provide that include. Without header context resolution, the +server cannot compile utils.h at all. +""" + +import asyncio + +import pytest +from lsprotocol.types import ( + HoverParams, + Position, + TextDocumentIdentifier, +) + + +def _doc(uri: str) -> TextDocumentIdentifier: + return TextDocumentIdentifier(uri=uri) + + +def _get(obj, key, default=None): + """Access a field from either a dict or an object with attributes.""" + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +@pytest.mark.workspace("header_context") +async def test_query_context_returns_host_sources(client, workspace): + """clice/queryContext on a header should return source files that include it.""" + await client.open_and_wait(workspace / "main.cpp") + + utils_h = workspace / "utils.h" + utils_uri, _ = client.open(utils_h) + + result = await asyncio.wait_for( + client.protocol.send_request_async("clice/queryContext", {"uri": utils_uri}), + timeout=30.0, + ) + assert result is not None + total = _get(result, "total") + contexts = _get(result, "contexts", []) + assert total >= 1, f"Should find at least main.cpp as context, got total={total}" + # Check that main.cpp is among the contexts. + uris = [_get(c, "uri") for c in contexts] + assert any("main.cpp" in u for u in uris), ( + f"main.cpp should be listed as a context option, got: {uris}" + ) + + +@pytest.mark.workspace("header_context") +async def test_query_context_source_file_returns_cdb_entries(client, workspace): + """clice/queryContext on a source file should return its CDB entries.""" + main_uri, _ = await client.open_and_wait(workspace / "main.cpp") + + result = await asyncio.wait_for( + client.protocol.send_request_async("clice/queryContext", {"uri": main_uri}), + timeout=30.0, + ) + assert result is not None + # header_context workspace has exactly 1 CDB entry for main.cpp. + assert _get(result, "total") == 1 + contexts = _get(result, "contexts", []) + assert len(contexts) == 1 + + +@pytest.mark.workspace("header_context") +async def test_current_context_default_null(client, workspace): + """clice/currentContext should return null context by default.""" + await client.open_and_wait(workspace / "main.cpp") + + utils_h = workspace / "utils.h" + utils_uri, _ = client.open(utils_h) + + result = await asyncio.wait_for( + client.protocol.send_request_async("clice/currentContext", {"uri": utils_uri}), + timeout=30.0, + ) + assert result is not None + assert _get(result, "context") is None, ( + "Default context should be null (no explicit override)" + ) + + +@pytest.mark.workspace("header_context") +async def test_switch_context_and_current_context(client, workspace): + """switchContext should set the active context, currentContext should reflect it.""" + main_uri, _ = await client.open_and_wait(workspace / "main.cpp") + + utils_h = workspace / "utils.h" + utils_uri, _ = client.open(utils_h) + + # Switch context to main.cpp. + switch_result = await asyncio.wait_for( + client.protocol.send_request_async( + "clice/switchContext", + {"uri": utils_uri, "contextUri": main_uri}, + ), + timeout=30.0, + ) + assert switch_result is not None + assert _get(switch_result, "success") is True + + # Verify currentContext now returns main.cpp. + current = await asyncio.wait_for( + client.protocol.send_request_async("clice/currentContext", {"uri": utils_uri}), + timeout=30.0, + ) + assert current is not None + ctx = _get(current, "context") + assert ctx is not None, ( + "After switchContext, currentContext should return the active context" + ) + assert "main.cpp" in _get(ctx, "uri") + + +@pytest.mark.workspace("header_context") +async def test_full_context_flow(client, workspace): + """Full flow: open, query, switch, verify hover works in header context.""" + # 1. Open main.cpp, wait for initial compile. + main_uri, _ = await client.open_and_wait(workspace / "main.cpp") + + # 2. Open utils.h (non self-contained header using Point from types.h). + utils_h = workspace / "utils.h" + utils_uri, _ = client.open(utils_h) + + # 3. queryContext on utils.h -> should return main.cpp as a context option. + query = await asyncio.wait_for( + client.protocol.send_request_async("clice/queryContext", {"uri": utils_uri}), + timeout=30.0, + ) + assert _get(query, "total") >= 1 + contexts = _get(query, "contexts", []) + context_uris = [_get(c, "uri") for c in contexts] + assert any("main.cpp" in u for u in context_uris) + + # 4. currentContext on utils.h -> should be null (default). + current = await asyncio.wait_for( + client.protocol.send_request_async("clice/currentContext", {"uri": utils_uri}), + timeout=30.0, + ) + assert _get(current, "context") is None + + # 5. switchContext on utils.h to main.cpp. + switch = await asyncio.wait_for( + client.protocol.send_request_async( + "clice/switchContext", + {"uri": utils_uri, "contextUri": main_uri}, + ), + timeout=30.0, + ) + assert _get(switch, "success") is True + + # 6. currentContext on utils.h -> should now be main.cpp. + current2 = await asyncio.wait_for( + client.protocol.send_request_async("clice/currentContext", {"uri": utils_uri}), + timeout=30.0, + ) + ctx = _get(current2, "context") + assert ctx is not None + assert "main.cpp" in _get(ctx, "uri") + + # 7. Hover on 'calc' function in utils.h -> should work (proves header compiled). + diag_event = client.wait_for_diagnostics(utils_uri) + hover = await asyncio.wait_for( + client.text_document_hover_async( + HoverParams( + text_document=_doc(utils_uri), + position=Position(line=6, character=12), # 'calc' function + ) + ), + timeout=30.0, + ) + assert hover is not None, ( + "Hover on 'calc' in header should work after switchContext" + ) + + # 8. Check diagnostics on utils.h -> should have 0 errors. + await asyncio.wait_for(diag_event.wait(), timeout=30.0) + diags = client.diagnostics.get(utils_uri, []) + errors = [d for d in diags if d.severity == 1] + assert len(errors) == 0, ( + f"Header should have no errors after switchContext, got: {errors}" + ) + + +@pytest.mark.workspace("header_context") +async def test_deep_nested_header_context(client, workspace): + """queryContext on a deeply nested header (main.cpp -> utils.h -> inner.h) + should still find main.cpp as the host source.""" + await client.open_and_wait(workspace / "main.cpp") + + inner_h = workspace / "inner.h" + inner_uri, _ = client.open(inner_h) + + # queryContext on inner.h should find main.cpp through the chain. + result = await asyncio.wait_for( + client.protocol.send_request_async("clice/queryContext", {"uri": inner_uri}), + timeout=30.0, + ) + assert result is not None + total = _get(result, "total") + assert total >= 1, f"Deep nested header should find host sources, got total={total}" + contexts = _get(result, "contexts", []) + uris = [_get(c, "uri") for c in contexts] + assert any("main.cpp" in u for u in uris), ( + f"main.cpp should be a context for inner.h, got: {uris}" + ) + + +@pytest.mark.workspace("header_context") +async def test_deep_nested_switch_context_and_hover(client, workspace): + """switchContext + hover on deeply nested header (main.cpp -> utils.h -> inner.h).""" + main_uri, _ = await client.open_and_wait(workspace / "main.cpp") + + inner_h = workspace / "inner.h" + inner_uri, _ = client.open(inner_h) + + # Switch inner.h context to main.cpp. + switch = await asyncio.wait_for( + client.protocol.send_request_async( + "clice/switchContext", + {"uri": inner_uri, "contextUri": main_uri}, + ), + timeout=30.0, + ) + assert _get(switch, "success") is True + + # Hover on 'inner_origin' in inner.h should work (Point available via preamble). + hover = await asyncio.wait_for( + client.text_document_hover_async( + HoverParams( + text_document=_doc(inner_uri), + position=Position(line=3, character=14), # 'inner_origin' + ) + ), + timeout=30.0, + ) + assert hover is not None, "Hover on inner_origin should work after switchContext" + + +@pytest.mark.workspace("multi_context") +async def test_query_context_multiple_cdb_entries(client, workspace): + """queryContext on a source file with multiple CDB entries should return all.""" + main_cpp = workspace / "main.cpp" + main_uri, _ = await client.open_and_wait(main_cpp) + + result = await asyncio.wait_for( + client.protocol.send_request_async("clice/queryContext", {"uri": main_uri}), + timeout=30.0, + ) + assert result is not None + total = _get(result, "total") + assert total >= 2, f"Should find at least 2 CDB entries, got total={total}" + contexts = _get(result, "contexts", []) + labels = [_get(c, "label") for c in contexts] + # Each entry should have distinguishing flags in the label. + assert any("CONFIG_A" in l for l in labels), f"Should find CONFIG_A, got: {labels}" + assert any("CONFIG_B" in l for l in labels), f"Should find CONFIG_B, got: {labels}"