diff --git a/src/server/master_server.cpp b/src/server/master_server.cpp index e497908e..1187da16 100644 --- a/src/server/master_server.cpp +++ b/src/server/master_server.cpp @@ -18,6 +18,8 @@ #include "syntax/dependency_graph.h" #include "syntax/scan.h" +#include "llvm/Support/xxhash.h" + namespace clice { namespace protocol = eventide::ipc::protocol; @@ -181,6 +183,14 @@ et::task<> MasterServer::run_build_drain(std::uint32_t path_id, std::string uri) } } + // Build or reuse PCH for preamble acceleration. + co_await ensure_pch(path_id, params.path, params.text, params.directory, params.arguments); + + // Populate PCH info if available. + if(auto pch_it = pch_paths.find(path_id); pch_it != pch_paths.end()) { + params.pch = {pch_it->second, pch_bounds[path_id]}; + } + LOG_DEBUG("Sending compile: path={}, args={}, gen={}", params.path, params.arguments.size(), @@ -379,6 +389,81 @@ bool MasterServer::fill_compile_args(llvm::StringRef path, return true; } +et::task MasterServer::ensure_pch(std::uint32_t path_id, + llvm::StringRef path, + const std::string& text, + const std::string& directory, + const std::vector& arguments) { + auto bound = compute_preamble_bound(text); + if(bound == 0) { + // No preamble directives — PCH would be empty. Clear any stale entry. + if(auto old_it = pch_paths.find(path_id); old_it != pch_paths.end()) { + fs::remove(old_it->second); + } + pch_paths.erase(path_id); + pch_bounds.erase(path_id); + pch_hashes.erase(path_id); + co_return true; + } + + auto preamble_hash = llvm::xxh3_64bits(llvm::StringRef(text).substr(0, bound)); + + // Reuse existing PCH if preamble content hasn't changed. + if(auto it = pch_hashes.find(path_id); it != pch_hashes.end()) { + if(it->second == preamble_hash && pch_paths.contains(path_id)) { + pch_bounds[path_id] = bound; + co_return true; + } + } + + // If another coroutine is already building PCH for this file, wait for it. + if(auto it = pch_building.find(path_id); it != pch_building.end()) { + co_await it->second->wait(); + co_return pch_paths.contains(path_id); + } + + // Register in-flight build so concurrent requests wait on us. + auto completion = std::make_shared(); + pch_building[path_id] = completion; + + // Build a new PCH via stateless worker. + worker::BuildPCHParams pch_params; + pch_params.file = std::string(path); + pch_params.directory = directory; + pch_params.arguments = arguments; + pch_params.content = text; + pch_params.preamble_bound = bound; + + LOG_DEBUG("Building PCH for {}, bound={}", path, bound); + + auto result = co_await pool.send_stateless(pch_params); + + if(!result.has_value() || !result.value().success) { + LOG_WARN("PCH build failed for {}: {}", + path, + result.has_value() ? result.value().error : result.error().message); + pch_building.erase(path_id); + completion->set(); + co_return false; + } + + // Delete old PCH temp file before replacing. + if(auto old_it = pch_paths.find(path_id); old_it != pch_paths.end()) { + fs::remove(old_it->second); + } + + pch_paths[path_id] = result.value().pch_path; + pch_bounds[path_id] = bound; + pch_hashes[path_id] = preamble_hash; + + LOG_INFO("PCH built for {}: {}", path, result.value().pch_path); + + // Signal waiters after state is fully updated, then remove in-flight entry. + pch_building.erase(path_id); + completion->set(); + co_return true; +} + et::task MasterServer::ensure_compiled(std::uint32_t path_id, const std::string& uri) { auto doc_it = documents.find(path_id); if(doc_it == documents.end()) @@ -459,7 +544,24 @@ MasterServer::RawResult MasterServer::forward_stateless(const std::string& uri, if(!fill_compile_args(path, wp.directory, wp.arguments)) co_return serde_raw{}; - lsp::PositionMapper mapper(doc.text, lsp::PositionEncoding::UTF16); + // Ensure PCH is available for stateless compilation (completion/signatureHelp). + co_await ensure_pch(path_id, path, wp.text, wp.directory, wp.arguments); + if(auto pch_it = pch_paths.find(path_id); pch_it != pch_paths.end()) { + wp.pch = {pch_it->second, pch_bounds[path_id]}; + } + + // Fill available PCM paths for module-aware completion. + // Skip the file's own PCM to avoid "multiple module declarations" errors. + for(auto& [pid, pcm_path]: pcm_paths) { + if(pid == path_id) + continue; + auto mod_it = path_to_module.find(pid); + if(mod_it != path_to_module.end()) { + wp.pcms[mod_it->second] = pcm_path; + } + } + + lsp::PositionMapper mapper(wp.text, lsp::PositionEncoding::UTF16); auto offset = mapper.to_offset(position); if(!offset) co_return serde_raw{"null"}; @@ -675,6 +777,9 @@ void MasterServer::register_handlers() { documents.erase(path_id); debounce_timers.erase(path_id); + pch_paths.erase(path_id); + pch_bounds.erase(path_id); + pch_hashes.erase(path_id); // Clear diagnostics for closed file clear_diagnostics(params.text_document.uri); @@ -711,6 +816,10 @@ void MasterServer::register_handlers() { } } + // Invalidate all cached PCH hashes — the saved file may be a header + // included by other TUs, so we must force rebuild for all open documents. + pch_hashes.clear(); + LOG_DEBUG("didSave: {}", params.text_document.uri); }); diff --git a/src/server/master_server.h b/src/server/master_server.h index 24fa1ff9..89f24f00 100644 --- a/src/server/master_server.h +++ b/src/server/master_server.h @@ -72,6 +72,18 @@ private: // path_id -> module name (for files that provide a module interface). llvm::DenseMap path_to_module; + // path_id -> built PCH file path. + llvm::DenseMap pch_paths; + + // path_id -> preamble bound (byte offset) used when building the PCH. + llvm::DenseMap pch_bounds; + + // path_id -> hash of preamble content at PCH build time (for staleness detection). + llvm::DenseMap pch_hashes; + + // path_id -> in-flight PCH build event (later arrivals co_await the same build). + llvm::DenseMap> pch_building; + // Document state: path_id -> DocumentState llvm::DenseMap documents; @@ -104,6 +116,13 @@ private: std::string& directory, std::vector& arguments); + // 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, + const std::string& text, + const std::string& directory, + const std::vector& arguments); + // Forwarding helpers for feature requests (RawValue passthrough) using RawResult = et::task; diff --git a/src/server/protocol.h b/src/server/protocol.h index 1aaff539..2afe86e3 100644 --- a/src/server/protocol.h +++ b/src/server/protocol.h @@ -97,11 +97,13 @@ struct BuildPCHParams { std::string directory; std::vector arguments; std::string content; + std::uint32_t preamble_bound = UINT32_MAX; }; struct BuildPCHResult { bool success; std::string error; + std::string pch_path; }; struct BuildPCMParams { diff --git a/src/server/stateless_worker.cpp b/src/server/stateless_worker.cpp index 2fe456f9..ce2a3263 100644 --- a/src/server/stateless_worker.cpp +++ b/src/server/stateless_worker.cpp @@ -71,12 +71,12 @@ int run_stateless_worker_mode() { CompilationParams cp; cp.kind = CompilationKind::Preamble; fill_args(cp, params.directory, params.arguments); - cp.add_remapped_file(params.file, params.content); + cp.add_remapped_file(params.file, params.content, params.preamble_bound); auto tmp = fs::createTemporaryFile("clice-pch", "pch"); if(!tmp) { LOG_ERROR("BuildPCH: failed to create temp file"); - return {false, "Failed to create temporary PCH file"}; + return {false, "Failed to create temporary PCH file", ""}; } cp.output_file = *tmp; @@ -84,11 +84,15 @@ int run_stateless_worker_mode() { auto unit = compile(cp, pch_info); if(unit.completed()) { - LOG_INFO("BuildPCH done: file={}, {}ms", params.file, timer.ms()); - return {true, ""}; + LOG_INFO("BuildPCH done: file={}, output={}, {}ms", + params.file, + cp.output_file, + timer.ms()); + return {true, "", std::string(cp.output_file)}; } else { LOG_WARN("BuildPCH failed: file={}, {}ms", params.file, timer.ms()); - return {false, "PCH compilation failed"}; + fs::remove(cp.output_file); + return {false, "PCH compilation failed", ""}; } }); co_return result.value(); diff --git a/tests/data/pch_test/common.h b/tests/data/pch_test/common.h new file mode 100644 index 00000000..cfb224b1 --- /dev/null +++ b/tests/data/pch_test/common.h @@ -0,0 +1,8 @@ +#pragma once + +struct Point { + int x; + int y; +}; + +int add(int a, int b); diff --git a/tests/data/pch_test/main.cpp b/tests/data/pch_test/main.cpp new file mode 100644 index 00000000..0656bcab --- /dev/null +++ b/tests/data/pch_test/main.cpp @@ -0,0 +1,11 @@ +#include "common.h" + +int add(int a, int b) { + return a + b; +} + +int main() { + Point p{1, 2}; + int result = add(p.x, p.y); + return result; +} diff --git a/tests/data/pch_test/no_includes.cpp b/tests/data/pch_test/no_includes.cpp new file mode 100644 index 00000000..f5eab9c6 --- /dev/null +++ b/tests/data/pch_test/no_includes.cpp @@ -0,0 +1,7 @@ +int square(int x) { + return x * x; +} + +int main() { + return square(3); +} diff --git a/tests/integration/test_pch.py b/tests/integration/test_pch.py new file mode 100644 index 00000000..5b9620e9 --- /dev/null +++ b/tests/integration/test_pch.py @@ -0,0 +1,104 @@ +"""Integration tests for PCH (precompiled header) functionality in MasterServer.""" + +import asyncio + +import pytest +from lsprotocol.types import ( + CompletionParams, + DidChangeTextDocumentParams, + DidCloseTextDocumentParams, + HoverParams, + Position, + TextDocumentContentChangeWholeDocument, + TextDocumentIdentifier, + VersionedTextDocumentIdentifier, +) + + +def _doc(uri: str) -> TextDocumentIdentifier: + return TextDocumentIdentifier(uri=uri) + + +@pytest.mark.workspace("pch_test") +async def test_pch_diagnostics_on_open(client, workspace): + """Opening a file with #include should trigger PCH build and return clean diagnostics.""" + uri, _ = await client.open_and_wait(workspace / "main.cpp") + assert uri in client.diagnostics + # main.cpp is well-formed, so diagnostics list should be empty (no errors). + diags = client.diagnostics[uri] + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + + +@pytest.mark.workspace("pch_test") +async def test_pch_body_edit_triggers_recompile(client, workspace): + """Editing only the body (not the preamble) should trigger recompilation.""" + uri, content = await client.open_and_wait(workspace / "main.cpp") + + # Edit only the function body — preamble (#include "common.h") unchanged. + new_content = content.replace("return result;", "return result + 1;") + event = client.wait_for_diagnostics(uri) + client.text_document_did_change( + DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier(uri=uri, version=1), + content_changes=[TextDocumentContentChangeWholeDocument(text=new_content)], + ) + ) + # The key assertion: recompilation completes (diagnostics event fires). + await asyncio.wait_for(event.wait(), timeout=30.0) + assert uri in client.diagnostics + client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + + +@pytest.mark.workspace("pch_test") +async def test_no_pch_for_no_includes(client, workspace): + """A file with no #include directives should compile without PCH.""" + uri, _ = await client.open_and_wait(workspace / "no_includes.cpp") + assert uri in client.diagnostics + diags = client.diagnostics[uri] + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + + +@pytest.mark.workspace("pch_test") +async def test_hover_on_local_symbol(client, workspace): + """Hover on a locally defined symbol should work when PCH is active.""" + uri, _ = await client.open_and_wait(workspace / "main.cpp") + + # Hover over "add" on line 2 (0-indexed): "int add(int a, int b) {" + result = await client.text_document_hover_async( + HoverParams(text_document=_doc(uri), position=Position(line=2, character=4)) + ) + assert result is not None + client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) + + +@pytest.mark.workspace("pch_test") +async def test_completion_with_pch(client, workspace): + """Completion should see symbols from PCH headers.""" + uri, content = await client.open_and_wait(workspace / "main.cpp") + + # Add a line that starts typing "Poi" to trigger completion for Point. + new_content = content + "\nPoi" + lines = new_content.split("\n") + last_line = len(lines) - 1 + + event = client.wait_for_diagnostics(uri) + client.text_document_did_change( + DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier(uri=uri, version=1), + content_changes=[TextDocumentContentChangeWholeDocument(text=new_content)], + ) + ) + # Brief wait for the change to be processed. + await asyncio.sleep(1.0) + + result = await client.text_document_completion_async( + CompletionParams( + text_document=_doc(uri), + position=Position(line=last_line, character=3), + ) + ) + # Completion should return results. + assert result is not None + client.text_document_did_close(DidCloseTextDocumentParams(text_document=_doc(uri))) diff --git a/tests/unit/compile/compilation_tests.cpp b/tests/unit/compile/compilation_tests.cpp index 677a3f13..79954b54 100644 --- a/tests/unit/compile/compilation_tests.cpp +++ b/tests/unit/compile/compilation_tests.cpp @@ -8,6 +8,8 @@ #include "support/filesystem.h" #include "syntax/scan.h" +#include "llvm/Support/xxhash.h" + namespace clice::testing { namespace { @@ -274,6 +276,64 @@ int bar() { return 3; } }; // TEST_SUITE(Compiler) +TEST_SUITE(PreambleHash) { + +TEST_CASE(StableForBodyChanges) { + // Same preamble (#include lines) but different body → same hash → PCH reusable. + llvm::StringRef v1 = R"cpp( +#include "a.h" +#include "b.h" +int x = 1; +)cpp"; + llvm::StringRef v2 = R"cpp( +#include "a.h" +#include "b.h" +int x = 2; +void foo() {} +)cpp"; + + auto bound1 = compute_preamble_bound(v1); + auto bound2 = compute_preamble_bound(v2); + EXPECT_EQ(bound1, bound2); + + auto hash1 = llvm::xxh3_64bits(v1.substr(0, bound1)); + auto hash2 = llvm::xxh3_64bits(v2.substr(0, bound2)); + EXPECT_EQ(hash1, hash2); +} + +TEST_CASE(ChangesForNewInclude) { + // Different preamble (#include added) → different hash → PCH must rebuild. + llvm::StringRef v1 = R"cpp( +#include "a.h" +int x = 1; +)cpp"; + llvm::StringRef v2 = R"cpp( +#include "a.h" +#include "b.h" +int x = 1; +)cpp"; + + auto bound1 = compute_preamble_bound(v1); + auto bound2 = compute_preamble_bound(v2); + EXPECT_NE(bound1, bound2); + + auto hash1 = llvm::xxh3_64bits(v1.substr(0, bound1)); + auto hash2 = llvm::xxh3_64bits(v2.substr(0, bound2)); + EXPECT_NE(hash1, hash2); +} + +TEST_CASE(ZeroBoundNoPCH) { + // No preprocessor directives → bound is 0 → PCH should be skipped. + llvm::StringRef code = R"cpp( +int main() { return 0; } +)cpp"; + + auto bound = compute_preamble_bound(code); + EXPECT_EQ(bound, 0u); +} + +}; // TEST_SUITE(PreambleHash) + } // namespace } // namespace clice::testing diff --git a/tests/unit/server/pch_worker_tests.cpp b/tests/unit/server/pch_worker_tests.cpp new file mode 100644 index 00000000..a8a11908 --- /dev/null +++ b/tests/unit/server/pch_worker_tests.cpp @@ -0,0 +1,153 @@ +#include +#include + +#include "test/test.h" +#include "server/protocol.h" +#include "server/worker_test_helpers.h" +#include "syntax/scan.h" + +namespace clice::testing { + +namespace { + +namespace et = eventide; + +// ============================================================================ +// End-to-end PCH compilation through real workers: +// 1. Stateless worker builds PCH for preamble headers +// 2. Stateful worker compiles a file using the PCH +// ============================================================================ + +TEST_SUITE(PCHWorker) { + +TEST_CASE(BuildPCHThenCompile) { + TempDir tmp; + + tmp.touch("common.h", R"cpp(struct Point { int x, y; };)cpp" "\n"); + auto header = tmp.path("common.h"); + + std::string main_text = "#include \"common.h\"\nPoint p{1,2};\n"; + tmp.touch("main.cpp", main_text); + auto main_file = tmp.path("main.cpp"); + + auto dir = std::string(tmp.root); + + // --- Phase 1: Build PCH via stateless worker --- + WorkerHandle sl; + ASSERT_TRUE(sl.spawn("stateless-worker")); + + std::string pch_path; + bool phase1_done = false; + + sl.run([&]() -> et::task<> { + worker::BuildPCHParams params; + params.file = main_file; + params.directory = dir; + params.arguments = {"clang++", + "-resource-dir", + std::string(resource_dir()), + "-x", + "c++-header", + "-I", + dir, + main_file}; + params.content = main_text; + + auto result = co_await sl.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value()); + CO_ASSERT_TRUE(result.value().success); + pch_path = result.value().pch_path; + EXPECT_FALSE(pch_path.empty()); + + phase1_done = true; + sl.peer->close_output(); + }); + + ASSERT_TRUE(phase1_done); + ASSERT_FALSE(pch_path.empty()); + + // Verify the PCH file exists on disk. + ASSERT_TRUE(llvm::sys::fs::exists(pch_path)); + + // --- Phase 2: Compile with PCH via stateful worker --- + WorkerHandle sf; + ASSERT_TRUE(sf.spawn("stateful-worker")); + + bool phase2_done = false; + + auto preamble_bound = compute_preamble_bound(main_text); + + sf.run([&]() -> et::task<> { + worker::CompileParams params; + params.path = main_file; + params.version = 1; + params.text = main_text; + params.directory = dir; + params.arguments = {"clang++", + "-resource-dir", + std::string(resource_dir()), + "-fsyntax-only", + "-I", + dir, + main_file}; + params.pch = {pch_path, preamble_bound}; + + auto result = co_await sf.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value().version, 1); + + phase2_done = true; + sf.peer->close_output(); + }); + + ASSERT_TRUE(phase2_done); + + // Cleanup PCH temp file. + std::remove(pch_path.c_str()); +} + +TEST_CASE(CompileWithoutPCHStillWorks) { + TempDir tmp; + + tmp.touch("common.h", R"cpp(struct Point { int x, y; };)cpp" "\n"); + std::string main_text = "#include \"common.h\"\nPoint p{1,2};\n"; + tmp.touch("main.cpp", main_text); + auto main_file = tmp.path("main.cpp"); + + auto dir = std::string(tmp.root); + + WorkerHandle sf; + ASSERT_TRUE(sf.spawn("stateful-worker")); + + bool compile_done = false; + + sf.run([&]() -> et::task<> { + worker::CompileParams params; + params.path = main_file; + params.version = 1; + params.text = main_text; + params.directory = dir; + params.arguments = {"clang++", + "-resource-dir", + std::string(resource_dir()), + "-fsyntax-only", + "-I", + dir, + main_file}; + // pch left as default (empty path, 0 bound). + + auto result = co_await sf.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value().version, 1); + + compile_done = true; + sf.peer->close_output(); + }); + + ASSERT_TRUE(compile_done); +} + +}; // TEST_SUITE(PCHWorker) + +} // namespace +} // namespace clice::testing diff --git a/tests/unit/server/stateless_worker_tests.cpp b/tests/unit/server/stateless_worker_tests.cpp index 0edd6072..41abc3c1 100644 --- a/tests/unit/server/stateless_worker_tests.cpp +++ b/tests/unit/server/stateless_worker_tests.cpp @@ -102,6 +102,12 @@ TEST_CASE(BuildPCHRequest) { auto result = co_await w.peer->send_request(params); EXPECT_TRUE(result.has_value()); + if(!result.has_value()) { + w.peer->close_output(); + co_return; + } + EXPECT_TRUE(result.value().success); + EXPECT_FALSE(result.value().pch_path.empty()); test_done = true; w.peer->close_output(); });