From 5528d9773bd164ac28b777689d7a90bfedb84b50 Mon Sep 17 00:00:00 2001 From: ykiko Date: Sat, 28 Mar 2026 23:16:01 +0800 Subject: [PATCH 1/5] feat: add CompileGraph for pull-based module dependency compilation Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 1 + src/server/compile_graph.cpp | 198 ++++++++++++++++++++ src/server/compile_graph.h | 78 ++++++++ tests/unit/server/compile_graph_tests.cpp | 217 ++++++++++++++++++++++ 4 files changed, 494 insertions(+) create mode 100644 src/server/compile_graph.cpp create mode 100644 src/server/compile_graph.h create mode 100644 tests/unit/server/compile_graph_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7798cfe94..08b2b533a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -172,6 +172,7 @@ add_library(clice-core STATIC "${PROJECT_SOURCE_DIR}/src/server/stateless_worker.cpp" "${PROJECT_SOURCE_DIR}/src/server/stateful_worker.cpp" "${PROJECT_SOURCE_DIR}/src/server/worker_pool.cpp" + "${PROJECT_SOURCE_DIR}/src/server/compile_graph.cpp" "${PROJECT_SOURCE_DIR}/src/server/master_server.cpp" "${PROJECT_SOURCE_DIR}/src/server/config.cpp" ) diff --git a/src/server/compile_graph.cpp b/src/server/compile_graph.cpp new file mode 100644 index 000000000..7ab16d945 --- /dev/null +++ b/src/server/compile_graph.cpp @@ -0,0 +1,198 @@ +#include "server/compile_graph.h" + +namespace clice { + +CompileGraph::CompileGraph(et::event_loop& loop, dispatch_fn dispatch) : + loop(loop), dispatch(std::move(dispatch)) {} + +void CompileGraph::register_unit(std::uint32_t path_id, llvm::ArrayRef deps) { + auto& unit = units[path_id]; + unit.path_id = path_id; + unit.dependencies.assign(deps.begin(), deps.end()); + + for(auto dep_id: deps) { + auto& dep_unit = units[dep_id]; + dep_unit.path_id = dep_id; + dep_unit.dependents.push_back(path_id); + } +} + +et::task CompileGraph::compile(std::uint32_t path_id) { + auto it = units.find(path_id); + if(it == units.end()) { + co_return true; + } + + // Compile each dependency sequentially (dedup is handled by compile_impl). + for(auto dep_id: it->second.dependencies) { + auto result = co_await et::with_token(compile_impl(dep_id), units[path_id].source->token()); + if(!result.has_value()) { + co_return false; // Cancelled. + } + if(!*result) { + co_return false; // Dep failed. + } + } + + co_return true; +} + +void CompileGraph::update(std::uint32_t path_id) { + llvm::SmallVector queue; + queue.push_back(path_id); + + while(!queue.empty()) { + auto current = queue.pop_back_val(); + + auto it = units.find(current); + if(it == units.end()) { + continue; + } + + auto& unit = it->second; + + // Skip if already dirty and not compiling (no work to do). + if(unit.dirty && !unit.compiling) { + continue; + } + + // Cancel any in-progress compilation and create a fresh source. + unit.source->cancel(); + unit.source = std::make_unique(); + unit.dirty = true; + + // Cascade to all dependents. + for(auto dep_id: unit.dependents) { + queue.push_back(dep_id); + } + } +} + +void CompileGraph::cancel_all() { + for(auto& [_, unit]: units) { + unit.source->cancel(); + } +} + +bool CompileGraph::has_unit(std::uint32_t path_id) const { + return units.count(path_id); +} + +bool CompileGraph::is_dirty(std::uint32_t path_id) const { + auto it = units.find(path_id); + return it != units.end() && it->second.dirty; +} + +bool CompileGraph::is_compiling(std::uint32_t path_id) const { + auto it = units.find(path_id); + return it != units.end() && it->second.compiling; +} + +et::task CompileGraph::compile_impl(std::uint32_t path_id, + llvm::SmallVector* stack) { + // Cycle detection. + if(stack) { + if(llvm::find(*stack, path_id) != stack->end()) { + co_return false; + } + stack->push_back(path_id); + } + + auto it = units.find(path_id); + if(it == units.end()) { + if(stack) { + stack->pop_back(); + } + co_return false; + } + + auto& unit = it->second; + + // Already clean. + if(!unit.dirty) { + if(stack) { + stack->pop_back(); + } + co_return true; + } + + // Another task is already compiling this unit — wait for it. + if(unit.compiling) { + co_await unit.completion->wait(); + auto& u = units.find(path_id)->second; + if(stack) { + stack->pop_back(); + } + co_return !u.dirty; + } + + // Begin compilation. + unit.compiling = true; + unit.completion = std::make_unique(); + + // Compile dependencies sequentially, each cancellable via this unit's token. + for(auto dep_id: units.find(path_id)->second.dependencies) { + auto dep_result = co_await et::with_token(compile_impl(dep_id, stack), + units.find(path_id)->second.source->token()); + if(!dep_result.has_value()) { + // Cancelled — cleanup and propagate. + auto& u = units.find(path_id)->second; + u.compiling = false; + u.completion->set(); + if(stack) { + stack->pop_back(); + } + co_await et::cancel(); + co_return false; // Unreachable. + } + if(!*dep_result) { + // Dependency failed. + auto& u = units.find(path_id)->second; + u.compiling = false; + u.completion->set(); + if(stack) { + stack->pop_back(); + } + co_return false; + } + } + + // All dependencies ready — dispatch the actual compilation. + { + auto& cur = units.find(path_id)->second; + auto result = co_await et::with_token(dispatch(path_id), cur.source->token()); + if(!result.has_value()) { + // Cancelled. + auto& u = units.find(path_id)->second; + u.compiling = false; + u.completion->set(); + if(stack) { + stack->pop_back(); + } + co_await et::cancel(); + co_return false; // Unreachable. + } + if(!*result) { + // Dispatch returned false (compilation failed). + auto& u = units.find(path_id)->second; + u.compiling = false; + u.completion->set(); + if(stack) { + stack->pop_back(); + } + co_return false; + } + } + + // Success. + auto& final_unit = units.find(path_id)->second; + final_unit.dirty = false; + final_unit.compiling = false; + final_unit.completion->set(); + if(stack) { + stack->pop_back(); + } + co_return true; +} + +} // namespace clice diff --git a/src/server/compile_graph.h b/src/server/compile_graph.h new file mode 100644 index 000000000..c8846a597 --- /dev/null +++ b/src/server/compile_graph.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +#include "eventide/async/async.h" + +#include "llvm/ADT/ArrayRef.h" +#include "llvm/ADT/DenseMap.h" +#include "llvm/ADT/SmallVector.h" + +namespace clice { + +namespace et = eventide; + +struct CompileUnit { + std::uint32_t path_id = 0; + llvm::SmallVector dependencies; + llvm::SmallVector dependents; + + bool dirty = true; + bool compiling = false; + + /// Per-unit cancellation source. update() cancels the current source + /// and creates a new one; waiters holding the old token observe cancellation. + std::unique_ptr source = std::make_unique(); + + /// Completion signal. When compiling == true, other callers + /// co_await completion->wait() instead of starting a duplicate compilation. + /// A new event is created each time compilation starts. + std::unique_ptr completion; +}; + +class CompileGraph { +public: + /// Callback that performs the actual compilation of a single artifact (PCM/PCH). + /// Returns true on success, false on failure. + using dispatch_fn = std::function(std::uint32_t path_id)>; + + explicit CompileGraph(et::event_loop& loop, dispatch_fn dispatch); + + /// Register a compilation unit with its dependencies. + /// Creates dependency nodes if they don't exist and populates + /// reverse (dependent) edges. + void register_unit(std::uint32_t path_id, llvm::ArrayRef deps); + + /// Ensure all dependencies of path_id are compiled. + /// Returns true if all dependencies are ready, false otherwise. + /// Does NOT compile path_id itself (source files are compiled by Workers). + et::task compile(std::uint32_t path_id); + + /// Mark path_id and all transitive dependents as dirty. + /// Cancels any in-progress compilations via their cancellation sources. + void update(std::uint32_t path_id); + + /// Cancel all in-progress compilations (for graceful shutdown). + void cancel_all(); + + // --- Accessors (for testing and diagnostics) --- + + bool has_unit(std::uint32_t path_id) const; + bool is_dirty(std::uint32_t path_id) const; + bool is_compiling(std::uint32_t path_id) const; + +private: + /// Compile a single dependency artifact. Handles deduplication + /// (wait if already compiling), cycle detection (optional stack), + /// and cancellation cleanup. + et::task compile_impl(std::uint32_t path_id, + llvm::SmallVector* stack = nullptr); + + et::event_loop& loop; + dispatch_fn dispatch; + llvm::DenseMap units; +}; + +} // namespace clice diff --git a/tests/unit/server/compile_graph_tests.cpp b/tests/unit/server/compile_graph_tests.cpp new file mode 100644 index 000000000..19d23b4a6 --- /dev/null +++ b/tests/unit/server/compile_graph_tests.cpp @@ -0,0 +1,217 @@ +#include "test/test.h" +#include "server/compile_graph.h" + +namespace clice::testing { +namespace { + +namespace et = eventide; + +/// Create a dispatch function that always succeeds instantly. +inline CompileGraph::dispatch_fn instant_dispatch() { + return [](std::uint32_t) -> et::task { + co_return true; + }; +} + +/// Create a dispatch function that records which units were compiled. +inline CompileGraph::dispatch_fn tracking_dispatch(std::vector& compiled) { + return [&compiled](std::uint32_t path_id) -> et::task { + compiled.push_back(path_id); + co_return true; + }; +} + +/// Create a dispatch function that always fails. +inline CompileGraph::dispatch_fn failing_dispatch() { + return [](std::uint32_t) -> et::task { + co_return false; + }; +} + +/// Helper: runs an async test. Creates event_loop + CompileGraph, +/// passes them to the test body, then the loop drains naturally. +template +void run_test(CompileGraph::dispatch_fn dispatch, F&& body) { + et::event_loop loop; + CompileGraph graph(loop, std::move(dispatch)); + + auto wrapper = [&]() -> et::task<> { + co_await body(graph); + }; + + auto t = wrapper(); + loop.schedule(t); + loop.run(); +} + +TEST_SUITE(CompileGraph) { + +TEST_CASE(RegisterUnit) { + run_test(instant_dispatch(), [this](CompileGraph& graph) -> et::task<> { + graph.register_unit(1, {2, 3}); + + EXPECT_TRUE(graph.has_unit(1)); + EXPECT_TRUE(graph.has_unit(2)); + EXPECT_TRUE(graph.has_unit(3)); + co_return; + }); +} + +TEST_CASE(CompileNoDeps) { + std::vector compiled; + run_test(tracking_dispatch(compiled), [this, &compiled](CompileGraph& graph) -> et::task<> { + graph.register_unit(1, {}); + + auto result = co_await graph.compile(1); + EXPECT_TRUE(result); + // compile() only ensures deps are ready; unit 1 has no deps, + // so dispatch is never called (source files are compiled by Workers). + EXPECT_TRUE(compiled.empty()); + }); +} + +TEST_CASE(CompileWithDependency) { + std::vector compiled; + run_test(tracking_dispatch(compiled), [this, &compiled](CompileGraph& graph) -> et::task<> { + // Unit 1 depends on unit 2. + graph.register_unit(1, {2}); + + auto result = co_await graph.compile(1); + EXPECT_TRUE(result); + EXPECT_EQ(compiled.size(), 1u); + EXPECT_EQ(compiled[0], 2u); + EXPECT_FALSE(graph.is_dirty(2)); + }); +} + +TEST_CASE(CompileChain) { + std::vector compiled; + run_test(tracking_dispatch(compiled), [this, &compiled](CompileGraph& graph) -> et::task<> { + // Chain: 1 -> 2 -> 3. + graph.register_unit(3, {}); + graph.register_unit(2, {3}); + graph.register_unit(1, {2}); + + auto result = co_await graph.compile(1); + EXPECT_TRUE(result); + + // Both 2 and 3 should have been compiled (3 first, then 2). + EXPECT_EQ(compiled.size(), 2u); + // 3 must be compiled before 2. + auto pos3 = std::find(compiled.begin(), compiled.end(), 3u); + auto pos2 = std::find(compiled.begin(), compiled.end(), 2u); + EXPECT_TRUE(pos3 < pos2); + }); +} + +TEST_CASE(DiamondDependency) { + std::vector compiled; + run_test(tracking_dispatch(compiled), [this, &compiled](CompileGraph& graph) -> et::task<> { + // Diamond: 1 -> {2, 3}, 2 -> 4, 3 -> 4. + graph.register_unit(4, {}); + graph.register_unit(3, {4}); + graph.register_unit(2, {4}); + graph.register_unit(1, {2, 3}); + + auto result = co_await graph.compile(1); + EXPECT_TRUE(result); + + // Unit 4 should be compiled exactly once (dedup). + auto count4 = std::count(compiled.begin(), compiled.end(), 4u); + EXPECT_EQ(count4, 1); + + // All deps should be clean. + EXPECT_FALSE(graph.is_dirty(2)); + EXPECT_FALSE(graph.is_dirty(3)); + EXPECT_FALSE(graph.is_dirty(4)); + }); +} + +TEST_CASE(UpdateInvalidates) { + run_test(instant_dispatch(), [this](CompileGraph& graph) -> et::task<> { + graph.register_unit(1, {2}); + + // Compile everything. + co_await graph.compile(1); + EXPECT_FALSE(graph.is_dirty(2)); + + // Update unit 2 — should become dirty again. + graph.update(2); + EXPECT_TRUE(graph.is_dirty(2)); + }); +} + +TEST_CASE(UpdateCascade) { + run_test(instant_dispatch(), [this](CompileGraph& graph) -> et::task<> { + // Chain: 1 -> 2 -> 3. + graph.register_unit(3, {}); + graph.register_unit(2, {3}); + graph.register_unit(1, {2}); + + // Compile everything. + co_await graph.compile(1); + EXPECT_FALSE(graph.is_dirty(2)); + EXPECT_FALSE(graph.is_dirty(3)); + + // Update leaf (3) — should cascade to 2 and 1. + graph.update(3); + EXPECT_TRUE(graph.is_dirty(3)); + EXPECT_TRUE(graph.is_dirty(2)); + EXPECT_TRUE(graph.is_dirty(1)); + }); +} + +TEST_CASE(CompileAfterUpdate) { + std::vector compiled; + run_test(tracking_dispatch(compiled), [this, &compiled](CompileGraph& graph) -> et::task<> { + graph.register_unit(1, {2}); + + // First compile. + co_await graph.compile(1); + EXPECT_EQ(compiled.size(), 1u); + + // Update and recompile. + graph.update(2); + co_await graph.compile(1); + + // Unit 2 should have been compiled a second time. + EXPECT_EQ(compiled.size(), 2u); + EXPECT_EQ(compiled[1], 2u); + }); +} + +TEST_CASE(CompileUnregistered) { + run_test(instant_dispatch(), [this](CompileGraph& graph) -> et::task<> { + // Compiling an unregistered unit should succeed (no deps to wait for). + auto result = co_await graph.compile(999); + EXPECT_TRUE(result); + }); +} + +TEST_CASE(DispatchFailure) { + run_test(failing_dispatch(), [this](CompileGraph& graph) -> et::task<> { + graph.register_unit(1, {2}); + + auto result = co_await graph.compile(1); + // Dependency compilation failed → compile returns false. + EXPECT_FALSE(result); + // Unit 2 should still be dirty (compilation failed). + EXPECT_TRUE(graph.is_dirty(2)); + }); +} + +TEST_CASE(CancelAll) { + run_test(instant_dispatch(), [this](CompileGraph& graph) -> et::task<> { + graph.register_unit(1, {}); + graph.register_unit(2, {}); + + // cancel_all should not crash. + graph.cancel_all(); + co_return; + }); +} + +}; // TEST_SUITE(CompileGraph) + +} // namespace +} // namespace clice::testing From cc4533616166640ad333db699b12f656259bb692 Mon Sep 17 00:00:00 2001 From: ykiko Date: Sun, 29 Mar 2026 02:52:15 +0800 Subject: [PATCH 2/5] fix: CompileGraph bug fixes, module impl unit support, comprehensive tests - Fix DenseMap dangling reference in ensure_resolved() with local copy - Fix cycle detection via by-value ancestors in compile_impl() - Fix update() cascade with visited set, resolved reset, stale edge cleanup - Fix self-PCM exclusion in run_build_drain() to prevent "multiple module declarations" error when recompiling module interfaces - Add module implementation unit PCM auto-build in run_build_drain() - Add 18 CompileGraph unit tests (cycle detection, diamond cascade, etc.) - Add 24 CompileGraph integration tests with real clang compilation - Add 3 module worker tests (PCM build, chain, impl unit) - Add 24 Python integration tests covering full LSP server pipeline Co-Authored-By: Claude Opus 4.6 --- src/server/compile_graph.cpp | 256 ++-- src/server/compile_graph.h | 52 +- src/server/master_server.cpp | 125 +- src/server/master_server.h | 10 + src/server/protocol.h | 1 + src/server/stateless_worker.cpp | 4 +- tests/integration/test_modules.py | 769 +++++++++++ .../compile_graph_integration_tests.cpp | 1228 +++++++++++++++++ tests/unit/server/compile_graph_tests.cpp | 528 +++++-- tests/unit/server/module_worker_tests.cpp | 274 ++++ 10 files changed, 2966 insertions(+), 281 deletions(-) create mode 100644 tests/integration/test_modules.py create mode 100644 tests/unit/server/compile_graph_integration_tests.cpp create mode 100644 tests/unit/server/module_worker_tests.cpp diff --git a/src/server/compile_graph.cpp b/src/server/compile_graph.cpp index 7ab16d945..bfc8d5ea6 100644 --- a/src/server/compile_graph.cpp +++ b/src/server/compile_graph.cpp @@ -1,49 +1,140 @@ #include "server/compile_graph.h" +#include + +#include "llvm/ADT/DenseSet.h" + namespace clice { -CompileGraph::CompileGraph(et::event_loop& loop, dispatch_fn dispatch) : - loop(loop), dispatch(std::move(dispatch)) {} +CompileGraph::CompileGraph(dispatch_fn dispatch, resolve_fn resolve) : + dispatch(std::move(dispatch)), resolve(std::move(resolve)) {} -void CompileGraph::register_unit(std::uint32_t path_id, llvm::ArrayRef deps) { +void CompileGraph::ensure_resolved(std::uint32_t path_id) { auto& unit = units[path_id]; + if(unit.resolved) { + return; + } + unit.path_id = path_id; - unit.dependencies.assign(deps.begin(), deps.end()); + unit.resolved = true; + unit.dependencies = resolve(path_id); + // Copy deps locally — the loop below may insert into `units`, + // which can rehash the DenseMap and invalidate the `unit` reference. + auto deps = units[path_id].dependencies; + + // Back-populate dependents. for(auto dep_id: deps) { - auto& dep_unit = units[dep_id]; - dep_unit.path_id = dep_id; - dep_unit.dependents.push_back(path_id); + auto& dep = units[dep_id]; + dep.path_id = dep_id; + dep.dependents.push_back(path_id); } } et::task CompileGraph::compile(std::uint32_t path_id) { + llvm::DenseSet ancestors; + co_return co_await compile_impl(path_id, ancestors); +} + +et::task CompileGraph::compile_impl(std::uint32_t path_id, + llvm::DenseSet ancestors) { + ensure_resolved(path_id); + + // Cycle detection: if this unit is already in the compile chain, bail out. + if(!ancestors.insert(path_id).second) { + co_return false; + } + + // Re-lookup after ensure_resolved may have mutated the map. auto it = units.find(path_id); - if(it == units.end()) { + + // Already clean. + if(!it->second.dirty) { co_return true; } - // Compile each dependency sequentially (dedup is handled by compile_impl). - for(auto dep_id: it->second.dependencies) { - auto result = co_await et::with_token(compile_impl(dep_id), units[path_id].source->token()); + // Another task is already compiling this unit — wait for it. + if(it->second.compiling) { + auto& completion = *it->second.completion; + co_await completion.wait(); + co_return !units.find(path_id)->second.dirty; + } + + // Begin compilation. + it->second.compiling = true; + it->second.completion = std::make_unique(); + + // Copy deps and token before co_await (DenseMap iterator safety). + auto deps = it->second.dependencies; + auto token = it->second.source->token(); + + // Compile all dependencies in parallel. + if(!deps.empty()) { + std::vector> dep_tasks; + dep_tasks.reserve(deps.size()); + for(auto dep_id: deps) { + dep_tasks.push_back(et::with_token(compile_impl(dep_id, ancestors), token)); + } + + auto results = co_await et::when_all(std::move(dep_tasks)); + + auto& u = units.find(path_id)->second; + if(results.is_cancelled()) { + u.compiling = false; + u.completion->set(); + co_await et::cancel(); + } + + for(auto ok: *results) { + if(!ok) { + u.compiling = false; + u.completion->set(); + co_return false; + } + } + } + + // Dispatch the actual compilation, cancellable via this unit's token. + { + auto result = + co_await et::with_token(dispatch(path_id), units.find(path_id)->second.source->token()); + + auto& u = units.find(path_id)->second; if(!result.has_value()) { - co_return false; // Cancelled. + u.compiling = false; + u.completion->set(); + co_await et::cancel(); } if(!*result) { - co_return false; // Dep failed. + u.compiling = false; + u.completion->set(); + co_return false; } } + // Success. + auto& final_unit = units.find(path_id)->second; + final_unit.dirty = false; + final_unit.compiling = false; + final_unit.completion->set(); co_return true; } -void CompileGraph::update(std::uint32_t path_id) { +llvm::SmallVector CompileGraph::update(std::uint32_t path_id) { llvm::SmallVector queue; + llvm::SmallVector dirtied; queue.push_back(path_id); + // Track visited nodes to avoid processing the same node twice. + llvm::DenseSet visited; + while(!queue.empty()) { auto current = queue.pop_back_val(); + if(!visited.insert(current).second) { + continue; + } + auto it = units.find(current); if(it == units.end()) { continue; @@ -51,21 +142,37 @@ void CompileGraph::update(std::uint32_t path_id) { auto& unit = it->second; - // Skip if already dirty and not compiling (no work to do). - if(unit.dirty && !unit.compiling) { - continue; + // Reset resolved so dependencies are re-scanned on next compile + // (the source file may have added/removed imports). + if(current == path_id) { + unit.resolved = false; + // Clear stale dependency edges — they'll be rebuilt by ensure_resolved. + for(auto dep_id: unit.dependencies) { + auto dep_it = units.find(dep_id); + if(dep_it != units.end()) { + auto& dependents = dep_it->second.dependents; + dependents.erase(std::remove(dependents.begin(), dependents.end(), path_id), + dependents.end()); + } + } + unit.dependencies.clear(); } - // Cancel any in-progress compilation and create a fresh source. - unit.source->cancel(); - unit.source = std::make_unique(); + // Cancel in-flight compilation if running. + if(unit.compiling) { + unit.source->cancel(); + unit.source = std::make_unique(); + } unit.dirty = true; + dirtied.push_back(current); - // Cascade to all dependents. + // Always propagate to dependents. for(auto dep_id: unit.dependents) { queue.push_back(dep_id); } } + + return dirtied; } void CompileGraph::cancel_all() { @@ -88,111 +195,4 @@ bool CompileGraph::is_compiling(std::uint32_t path_id) const { return it != units.end() && it->second.compiling; } -et::task CompileGraph::compile_impl(std::uint32_t path_id, - llvm::SmallVector* stack) { - // Cycle detection. - if(stack) { - if(llvm::find(*stack, path_id) != stack->end()) { - co_return false; - } - stack->push_back(path_id); - } - - auto it = units.find(path_id); - if(it == units.end()) { - if(stack) { - stack->pop_back(); - } - co_return false; - } - - auto& unit = it->second; - - // Already clean. - if(!unit.dirty) { - if(stack) { - stack->pop_back(); - } - co_return true; - } - - // Another task is already compiling this unit — wait for it. - if(unit.compiling) { - co_await unit.completion->wait(); - auto& u = units.find(path_id)->second; - if(stack) { - stack->pop_back(); - } - co_return !u.dirty; - } - - // Begin compilation. - unit.compiling = true; - unit.completion = std::make_unique(); - - // Compile dependencies sequentially, each cancellable via this unit's token. - for(auto dep_id: units.find(path_id)->second.dependencies) { - auto dep_result = co_await et::with_token(compile_impl(dep_id, stack), - units.find(path_id)->second.source->token()); - if(!dep_result.has_value()) { - // Cancelled — cleanup and propagate. - auto& u = units.find(path_id)->second; - u.compiling = false; - u.completion->set(); - if(stack) { - stack->pop_back(); - } - co_await et::cancel(); - co_return false; // Unreachable. - } - if(!*dep_result) { - // Dependency failed. - auto& u = units.find(path_id)->second; - u.compiling = false; - u.completion->set(); - if(stack) { - stack->pop_back(); - } - co_return false; - } - } - - // All dependencies ready — dispatch the actual compilation. - { - auto& cur = units.find(path_id)->second; - auto result = co_await et::with_token(dispatch(path_id), cur.source->token()); - if(!result.has_value()) { - // Cancelled. - auto& u = units.find(path_id)->second; - u.compiling = false; - u.completion->set(); - if(stack) { - stack->pop_back(); - } - co_await et::cancel(); - co_return false; // Unreachable. - } - if(!*result) { - // Dispatch returned false (compilation failed). - auto& u = units.find(path_id)->second; - u.compiling = false; - u.completion->set(); - if(stack) { - stack->pop_back(); - } - co_return false; - } - } - - // Success. - auto& final_unit = units.find(path_id)->second; - final_unit.dirty = false; - final_unit.compiling = false; - final_unit.completion->set(); - if(stack) { - stack->pop_back(); - } - co_return true; -} - } // namespace clice diff --git a/src/server/compile_graph.h b/src/server/compile_graph.h index c8846a597..de87b6eb2 100644 --- a/src/server/compile_graph.h +++ b/src/server/compile_graph.h @@ -6,8 +6,8 @@ #include "eventide/async/async.h" -#include "llvm/ADT/ArrayRef.h" #include "llvm/ADT/DenseMap.h" +#include "llvm/ADT/DenseSet.h" #include "llvm/ADT/SmallVector.h" namespace clice { @@ -16,62 +16,56 @@ namespace et = eventide; struct CompileUnit { std::uint32_t path_id = 0; + + /// Dependencies discovered lazily by resolve_fn. llvm::SmallVector dependencies; + + /// Back-edges: units that depend on this unit. llvm::SmallVector dependents; + /// Whether resolve_fn has been called for this unit. + bool resolved = false; + bool dirty = true; bool compiling = false; - /// Per-unit cancellation source. update() cancels the current source - /// and creates a new one; waiters holding the old token observe cancellation. std::unique_ptr source = std::make_unique(); - - /// Completion signal. When compiling == true, other callers - /// co_await completion->wait() instead of starting a duplicate compilation. - /// A new event is created each time compilation starts. std::unique_ptr completion; }; class CompileGraph { public: - /// Callback that performs the actual compilation of a single artifact (PCM/PCH). - /// Returns true on success, false on failure. + /// Performs the actual compilation (e.g. produce PCM file). using dispatch_fn = std::function(std::uint32_t path_id)>; - explicit CompileGraph(et::event_loop& loop, dispatch_fn dispatch); + /// Returns the dependency path_ids for a given path_id (called lazily on first compile). + using resolve_fn = std::function(std::uint32_t path_id)>; - /// Register a compilation unit with its dependencies. - /// Creates dependency nodes if they don't exist and populates - /// reverse (dependent) edges. - void register_unit(std::uint32_t path_id, llvm::ArrayRef deps); + CompileGraph(dispatch_fn dispatch, resolve_fn resolve); - /// Ensure all dependencies of path_id are compiled. - /// Returns true if all dependencies are ready, false otherwise. - /// Does NOT compile path_id itself (source files are compiled by Workers). + /// Compile a unit and all its transitive dependencies. et::task compile(std::uint32_t path_id); - /// Mark path_id and all transitive dependents as dirty. - /// Cancels any in-progress compilations via their cancellation sources. - void update(std::uint32_t path_id); + /// Mark path_id and all transitive dependents as dirty, + /// cancelling any in-progress compilations. + /// Returns the set of all path_ids that were marked dirty. + llvm::SmallVector update(std::uint32_t path_id); - /// Cancel all in-progress compilations (for graceful shutdown). void cancel_all(); - // --- Accessors (for testing and diagnostics) --- - bool has_unit(std::uint32_t path_id) const; bool is_dirty(std::uint32_t path_id) const; bool is_compiling(std::uint32_t path_id) const; private: - /// Compile a single dependency artifact. Handles deduplication - /// (wait if already compiling), cycle detection (optional stack), - /// and cancellation cleanup. - et::task compile_impl(std::uint32_t path_id, - llvm::SmallVector* stack = nullptr); + /// Get or create a unit, resolving its dependencies if needed. + void ensure_resolved(std::uint32_t path_id); + + /// Internal compile with ancestor tracking for cycle detection. + et::task compile_impl(std::uint32_t path_id, llvm::DenseSet ancestors); - et::event_loop& loop; dispatch_fn dispatch; + resolve_fn resolve; llvm::DenseMap units; }; diff --git a/src/server/master_server.cpp b/src/server/master_server.cpp index 7b4beaf00..cb98866a7 100644 --- a/src/server/master_server.cpp +++ b/src/server/master_server.cpp @@ -15,6 +15,7 @@ #include "support/filesystem.h" #include "support/logging.h" #include "syntax/dependency_graph.h" +#include "syntax/scan.h" namespace clice { @@ -103,6 +104,31 @@ et::task<> MasterServer::run_build_drain(std::uint32_t path_id, std::string uri) doc_it->second.build_requested = false; auto gen = doc_it->second.generation; + // Ensure module dependencies are compiled first. + if(compile_graph) { + // Scan this file for module imports and compile them. + auto file_path = path_pool.resolve(path_id); + auto cdb_results = + cdb.lookup(file_path, {.query_toolchain = true, .suppress_logging = true}); + if(!cdb_results.empty()) { + auto scan_result = scan_precise(cdb_results[0].arguments, cdb_results[0].directory); + for(auto& mod_name: scan_result.modules) { + auto mod_ids = dependency_graph.lookup_module(mod_name); + if(!mod_ids.empty()) { + co_await compile_graph->compile(mod_ids[0]).catch_cancel(); + } + } + // Module implementation units (module M; without export) need + // their interface PCM but don't have an explicit import. + if(!scan_result.module_name.empty() && !scan_result.is_interface_unit) { + auto mod_ids = dependency_graph.lookup_module(scan_result.module_name); + if(!mod_ids.empty()) { + co_await compile_graph->compile(mod_ids[0]).catch_cancel(); + } + } + } + } + // Send compile request to stateful worker worker::CompileParams params; params.path = std::string(path_pool.resolve(path_id)); @@ -110,6 +136,18 @@ et::task<> MasterServer::run_build_drain(std::uint32_t path_id, std::string uri) params.text = doc_it->second.text; fill_compile_args(path_pool.resolve(path_id), params.directory, params.arguments); + // Fill all available PCM paths (clang needs transitive deps). + // Skip the file's own PCM — a module interface must not receive its + // own precompiled module, or clang reports "multiple module declarations". + 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()) { + params.pcms[mod_it->second] = pcm_path; + } + } + LOG_DEBUG("Sending compile: path={}, args={}, gen={}", params.path, params.arguments.size(), @@ -217,12 +255,86 @@ et::task<> MasterServer::load_workspace() { if(unresolved > 0) { LOG_WARN("{} unresolved includes", unresolved); } + + // Build reverse mapping: path_id -> module name. + for(auto& [module_name, path_ids]: dependency_graph.modules()) { + for(auto path_id: path_ids) { + path_to_module[path_id] = module_name.str(); + } + } + + if(path_to_module.empty()) { + LOG_INFO("No C++20 modules detected, skipping CompileGraph"); + co_return; + } + + // 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 = path_pool.resolve(path_id); + auto results = cdb.lookup(file_path, {.query_toolchain = true, .suppress_logging = true}); + if(results.empty()) { + return {}; + } + + auto& ctx = results[0]; + auto scan_result = scan_precise(ctx.arguments, ctx.directory); + + llvm::SmallVector deps; + for(auto& mod_name: scan_result.modules) { + auto mod_ids = dependency_graph.lookup_module(mod_name); + if(!mod_ids.empty()) { + deps.push_back(mod_ids[0]); + } + } + return deps; + }; + + // Dispatch: sends BuildPCM request to a stateless worker. + auto dispatch = [this](std::uint32_t path_id) -> et::task { + auto mod_it = path_to_module.find(path_id); + if(mod_it == path_to_module.end()) { + co_return false; + } + + auto file_path = std::string(path_pool.resolve(path_id)); + worker::BuildPCMParams pcm_params; + pcm_params.file = file_path; + fill_compile_args(file_path, pcm_params.directory, pcm_params.arguments); + pcm_params.module_name = mod_it->second; + + // Clang needs ALL transitive PCM deps, not just direct imports. + for(auto& [pid, pcm_path]: pcm_paths) { + auto dep_mod_it = path_to_module.find(pid); + if(dep_mod_it != path_to_module.end()) { + pcm_params.pcms[dep_mod_it->second] = pcm_path; + } + } + + auto result = co_await pool.send_stateless(pcm_params); + if(!result.has_value() || !result.value().success) { + LOG_WARN("BuildPCM failed for module {}: {}", + mod_it->second, + result.has_value() ? result.value().error : result.error().message); + co_return false; + } + + pcm_paths[path_id] = result.value().pcm_path; + LOG_INFO("Built PCM for module {}: {}", mod_it->second, result.value().pcm_path); + co_return true; + }; + + compile_graph = std::make_unique(std::move(dispatch), std::move(resolve)); + LOG_INFO("CompileGraph initialized with {} module(s)", path_to_module.size()); } void MasterServer::fill_compile_args(llvm::StringRef path, std::string& directory, std::vector& arguments) { auto results = cdb.lookup(path, {.query_toolchain = true}); + if(results.empty()) { + LOG_WARN("No CDB entry for {}", path); + return; + } auto& ctx = results.front(); directory = ctx.directory.str(); arguments.clear(); @@ -527,7 +639,18 @@ void MasterServer::register_handlers() { if(lifecycle != ServerLifecycle::Ready) return; - // TODO: Trigger dependent file rebuilds + auto path = uri_to_path(params.text_document.uri); + auto path_id = path_pool.intern(path); + + // Invalidate this file and cascade to dependents in the compile graph. + if(compile_graph) { + auto dirtied = compile_graph->update(path_id); + // Remove stale PCMs for all invalidated units. + for(auto dirty_id: dirtied) { + pcm_paths.erase(dirty_id); + } + } + LOG_DEBUG("didSave: {}", params.text_document.uri); }); diff --git a/src/server/master_server.h b/src/server/master_server.h index f87d582da..25a467c2b 100644 --- a/src/server/master_server.h +++ b/src/server/master_server.h @@ -9,6 +9,7 @@ #include "eventide/ipc/lsp/protocol.h" #include "eventide/ipc/peer.h" #include "eventide/serde/serde/raw_value.h" +#include "server/compile_graph.h" #include "server/config.h" #include "server/worker_pool.h" #include "support/path_pool.h" @@ -61,6 +62,15 @@ class MasterServer { CompilationDatabase cdb; DependencyGraph dependency_graph; + // Module compilation graph (lazy dependency resolution). + std::unique_ptr compile_graph; + + // path_id -> built PCM output path (set after successful module build). + llvm::DenseMap pcm_paths; + + // path_id -> module name (for files that provide a module interface). + llvm::DenseMap path_to_module; + // Document state: path_id -> DocumentState llvm::DenseMap documents; diff --git a/src/server/protocol.h b/src/server/protocol.h index 03e6a2b8a..1aaff539e 100644 --- a/src/server/protocol.h +++ b/src/server/protocol.h @@ -115,6 +115,7 @@ struct BuildPCMParams { struct BuildPCMResult { bool success; std::string error; + std::string pcm_path; }; struct IndexParams { diff --git a/src/server/stateless_worker.cpp b/src/server/stateless_worker.cpp index 1cfe47b26..2fe456f9f 100644 --- a/src/server/stateless_worker.cpp +++ b/src/server/stateless_worker.cpp @@ -122,10 +122,10 @@ int run_stateless_worker_mode() { if(unit.completed()) { LOG_INFO("BuildPCM done: module={}, {}ms", params.module_name, timer.ms()); - return {true, ""}; + return {true, "", std::string(cp.output_file)}; } else { LOG_WARN("BuildPCM failed: module={}, {}ms", params.module_name, timer.ms()); - return {false, "PCM compilation failed"}; + return {false, "PCM compilation failed", ""}; } }); co_return result.value(); diff --git a/tests/integration/test_modules.py b/tests/integration/test_modules.py new file mode 100644 index 000000000..e27128cf7 --- /dev/null +++ b/tests/integration/test_modules.py @@ -0,0 +1,769 @@ +"""Integration tests for C++20 module support through the full LSP server. + +These are the Python equivalents of the C++ compile_graph_integration_tests +and module_worker_tests. They test the complete pipeline: + MasterServer -> CompileGraph -> WorkerPool -> stateless/stateful workers. +""" + +import json +import asyncio +from pathlib import Path + +import pytest +from lsprotocol.types import ( + ClientCapabilities, + DidCloseTextDocumentParams, + DidOpenTextDocumentParams, + DidSaveTextDocumentParams, + HoverParams, + InitializeParams, + InitializedParams, + Position, + TextDocumentIdentifier, + TextDocumentItem, + WorkspaceFolder, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write_cdb(workspace: Path, files: list[str], extra_args: list[str] | None = None): + """Generate compile_commands.json for the given source files.""" + cdb = [] + for f in files: + args = ["clang++", "-std=c++20", "-fsyntax-only"] + if extra_args: + args.extend(extra_args) + args.append(str(workspace / f)) + cdb.append( + { + "directory": str(workspace), + "file": str(workspace / f), + "arguments": args, + } + ) + (workspace / "compile_commands.json").write_text(json.dumps(cdb, indent=2)) + + +def _write_cdb_entries(workspace: Path, entries: list[tuple[str, list[str]]]): + """Generate compile_commands.json with per-file extra args. + + entries: list of (filename, extra_args) tuples. + """ + cdb = [] + for filename, extra in entries: + args = ["clang++", "-std=c++20", "-fsyntax-only"] + args.extend(extra) + args.append(str(workspace / filename)) + cdb.append( + { + "directory": str(workspace), + "file": str(workspace / filename), + "arguments": args, + } + ) + (workspace / "compile_commands.json").write_text(json.dumps(cdb, indent=2)) + + +async def _init(client, workspace: Path): + """Initialize the LSP server with a workspace.""" + result = await client.initialize_async( + InitializeParams( + capabilities=ClientCapabilities(), + root_uri=workspace.as_uri(), + workspace_folders=[WorkspaceFolder(uri=workspace.as_uri(), name="test")], + ) + ) + client.initialized(InitializedParams()) + # Give the server time to load CDB and scan dependency graph. + await asyncio.sleep(1.0) + return result + + +def _open(client, workspace: Path, filename: str, version: int = 0): + """Open a file and return its URI.""" + path = workspace / filename + content = path.read_text(encoding="utf-8") + uri = path.as_uri() + client.text_document_did_open( + DidOpenTextDocumentParams( + text_document=TextDocumentItem( + uri=uri, language_id="cpp", version=version, text=content + ) + ) + ) + return uri, content + + +async def _open_and_wait(client, workspace: Path, filename: str, timeout: float = 60.0): + """Open a file and wait for compilation diagnostics.""" + uri, content = _open(client, workspace, filename) + event = client.wait_for_diagnostics(uri) + await asyncio.wait_for(event.wait(), timeout=timeout) + return uri, content + + +# --------------------------------------------------------------------------- +# Single module (no dependencies) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_single_module_no_deps(client, tmp_path): + """A single module with no imports should compile without errors.""" + (tmp_path / "mod_a.cppm").write_text( + "export module A;\nexport int foo() { return 42; }\n" + ) + _write_cdb(tmp_path, ["mod_a.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "mod_a.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Chained modules (A -> B, open B) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_chained_modules(client, tmp_path): + """Opening a module that imports another should trigger dependency compilation.""" + (tmp_path / "mod_a.cppm").write_text( + "export module A;\nexport int foo() { return 42; }\n" + ) + (tmp_path / "mod_b.cppm").write_text( + "export module B;\nimport A;\nexport int bar() { return foo() + 1; }\n" + ) + _write_cdb(tmp_path, ["mod_a.cppm", "mod_b.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "mod_b.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Diamond dependency (Base -> Left/Right -> Top) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_diamond_modules(client, tmp_path): + """Diamond dependency graph should compile correctly.""" + (tmp_path / "base.cppm").write_text( + "export module Base;\nexport int base_val() { return 10; }\n" + ) + (tmp_path / "left.cppm").write_text( + "export module Left;\n" + "import Base;\n" + "export int left_val() { return base_val() + 1; }\n" + ) + (tmp_path / "right.cppm").write_text( + "export module Right;\n" + "import Base;\n" + "export int right_val() { return base_val() + 2; }\n" + ) + (tmp_path / "top.cppm").write_text( + "export module Top;\n" + "import Left;\n" + "import Right;\n" + "export int top_val() { return left_val() + right_val(); }\n" + ) + _write_cdb(tmp_path, ["base.cppm", "left.cppm", "right.cppm", "top.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "top.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Dotted module name (my.io, my.app) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_dotted_module_name(client, tmp_path): + """Dotted module names should work correctly.""" + (tmp_path / "io.cppm").write_text("export module my.io;\nexport void print() {}\n") + (tmp_path / "app.cppm").write_text( + "export module my.app;\nimport my.io;\nexport void run() { print(); }\n" + ) + _write_cdb(tmp_path, ["io.cppm", "app.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "app.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Module implementation unit (module M; without export) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_module_implementation_unit(client, tmp_path): + """A module implementation unit should compile using the interface PCM.""" + (tmp_path / "greeter.cppm").write_text( + "export module Greeter;\nexport const char* greet();\n" + ) + (tmp_path / "greeter_impl.cpp").write_text( + 'module Greeter;\nconst char* greet() { return "hello"; }\n' + ) + _write_cdb(tmp_path, ["greeter.cppm", "greeter_impl.cpp"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "greeter_impl.cpp") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Consumer file that imports a module (regular .cpp) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_consumer_imports_module(client, tmp_path): + """A regular .cpp file that imports a module should get PCM deps compiled.""" + (tmp_path / "math.cppm").write_text( + "export module Math;\nexport int add(int a, int b) { return a + b; }\n" + ) + (tmp_path / "main.cpp").write_text( + "import Math;\nint main() { return add(1, 2); }\n" + ) + _write_cdb(tmp_path, ["math.cppm", "main.cpp"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "main.cpp") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Module partitions (multiple partitions) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_module_partitions(client, tmp_path): + """Module partitions should be compiled in correct order.""" + (tmp_path / "part_a.cppm").write_text( + "export module Lib:A;\nexport int a_fn() { return 1; }\n" + ) + (tmp_path / "part_b.cppm").write_text( + "export module Lib:B;\nexport int b_fn() { return 2; }\n" + ) + (tmp_path / "lib.cppm").write_text( + "export module Lib;\n" + "export import :A;\n" + "export import :B;\n" + "export int lib_fn() { return a_fn() + b_fn(); }\n" + ) + _write_cdb(tmp_path, ["part_a.cppm", "part_b.cppm", "lib.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "lib.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Partition interface (single partition) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_partition_interface(client, tmp_path): + """A single partition interface re-exported from primary should compile.""" + (tmp_path / "part.cppm").write_text( + "export module M:Part;\nexport int part_fn() { return 5; }\n" + ) + (tmp_path / "primary.cppm").write_text( + "export module M;\n" + "export import :Part;\n" + "export int primary_fn() { return part_fn() + 1; }\n" + ) + _write_cdb(tmp_path, ["part.cppm", "primary.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "primary.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Partition chain (partition importing another partition) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_partition_chain(client, tmp_path): + """Partition importing another partition within same module.""" + (tmp_path / "types.cppm").write_text( + "export module Sys:Types;\nexport struct Config { int value = 0; };\n" + ) + (tmp_path / "core.cppm").write_text( + "export module Sys:Core;\n" + "import :Types;\n" + "export Config make_config() { return {42}; }\n" + ) + (tmp_path / "sys.cppm").write_text( + "export module Sys;\nexport import :Types;\nexport import :Core;\n" + ) + _write_cdb(tmp_path, ["types.cppm", "core.cppm", "sys.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "sys.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Re-export (export import) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_re_export(client, tmp_path): + """Re-exported module symbols should be accessible through the wrapper.""" + (tmp_path / "core.cppm").write_text( + "export module Core;\nexport int core_fn() { return 1; }\n" + ) + (tmp_path / "wrapper.cppm").write_text( + "export module Wrapper;\n" + "export import Core;\n" + "export int wrap_fn() { return core_fn() + 10; }\n" + ) + (tmp_path / "user.cppm").write_text( + "export module User;\n" + "import Wrapper;\n" + "export int use_fn() { return core_fn() + wrap_fn(); }\n" + ) + _write_cdb(tmp_path, ["core.cppm", "wrapper.cppm", "user.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "user.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Export block syntax +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_export_block(client, tmp_path): + """Module with export block syntax should compile correctly.""" + (tmp_path / "block.cppm").write_text( + "export module Block;\n" + "export {\n" + " int alpha() { return 1; }\n" + " int beta() { return 2; }\n" + " namespace ns {\n" + " int gamma() { return 3; }\n" + " }\n" + "}\n" + ) + (tmp_path / "consumer.cppm").write_text( + "export module Consumer;\n" + "import Block;\n" + "export int total() { return alpha() + beta() + ns::gamma(); }\n" + ) + _write_cdb(tmp_path, ["block.cppm", "consumer.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "consumer.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Global module fragment +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_global_module_fragment(client, tmp_path): + """Module with global module fragment (#include before module decl).""" + (tmp_path / "legacy.h").write_text("inline int legacy_fn() { return 99; }\n") + (tmp_path / "gmf.cppm").write_text( + "module;\n" + '#include "legacy.h"\n' + "export module GMF;\n" + "export int wrapped() { return legacy_fn(); }\n" + ) + _write_cdb(tmp_path, ["gmf.cppm"], extra_args=["-I", str(tmp_path)]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "gmf.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Private module fragment +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_private_module_fragment(client, tmp_path): + """Module with private module fragment should compile correctly.""" + (tmp_path / "priv.cppm").write_text( + "export module Priv;\n" + "export int public_fn();\n" + "module : private;\n" + "int public_fn() { return 42; }\n" + "int private_helper() { return 7; }\n" + ) + _write_cdb(tmp_path, ["priv.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "priv.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Export namespace +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_export_namespace(client, tmp_path): + """Module with exported namespace should compile correctly.""" + (tmp_path / "ns.cppm").write_text( + "export module NS;\n" + "export namespace math {\n" + " int add(int a, int b) { return a + b; }\n" + " int mul(int a, int b) { return a * b; }\n" + "}\n" + ) + (tmp_path / "calc.cppm").write_text( + "export module Calc;\n" + "import NS;\n" + "export int compute() { return math::add(3, math::mul(4, 5)); }\n" + ) + _write_cdb(tmp_path, ["ns.cppm", "calc.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "calc.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# GMF with include + module import +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_gmf_with_import(client, tmp_path): + """Module with GMF (#include) + import should compile correctly.""" + (tmp_path / "util.h").write_text("inline int util_helper() { return 7; }\n") + (tmp_path / "base.cppm").write_text( + "export module Base;\nexport int base() { return 100; }\n" + ) + (tmp_path / "combined.cppm").write_text( + "module;\n" + '#include "util.h"\n' + "export module Combined;\n" + "import Base;\n" + "export int combined() { return base() + util_helper(); }\n" + ) + _write_cdb_entries( + tmp_path, + [ + ("base.cppm", []), + ("combined.cppm", ["-I", str(tmp_path)]), + ], + ) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "combined.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Independent modules (no shared deps) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_independent_modules(client, tmp_path): + """Two independent modules should each compile without errors.""" + (tmp_path / "x.cppm").write_text("export module X;\nexport int x() { return 1; }\n") + (tmp_path / "y.cppm").write_text("export module Y;\nexport int y() { return 2; }\n") + _write_cdb(tmp_path, ["x.cppm", "y.cppm"]) + await _init(client, tmp_path) + + uri_x, _ = await _open_and_wait(client, tmp_path, "x.cppm") + diags_x = client.diagnostics.get(uri_x, []) + assert len(diags_x) == 0, f"Expected no diagnostics for X, got: {diags_x}" + + uri_y, _ = await _open_and_wait(client, tmp_path, "y.cppm") + diags_y = client.diagnostics.get(uri_y, []) + assert len(diags_y) == 0, f"Expected no diagnostics for Y, got: {diags_y}" + + +# --------------------------------------------------------------------------- +# Template export +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_template_export(client, tmp_path): + """Module with exported templates should compile correctly.""" + (tmp_path / "tmpl.cppm").write_text( + "export module Tmpl;\n" + "export template\n" + "T identity(T x) { return x; }\n" + "export template\n" + "auto pair_sum(T a, U b) { return a + b; }\n" + ) + (tmp_path / "use_tmpl.cppm").write_text( + "export module UseTmpl;\n" + "import Tmpl;\n" + "export int test() { return identity(42) + pair_sum(1, 2); }\n" + ) + _write_cdb(tmp_path, ["tmpl.cppm", "use_tmpl.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "use_tmpl.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Class export and inheritance across modules +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_class_export_and_inheritance(client, tmp_path): + """Exported class with cross-module inheritance should compile.""" + (tmp_path / "shape.cppm").write_text( + "export module Shape;\n" + "export class Shape {\n" + "public:\n" + " virtual ~Shape() = default;\n" + " virtual int area() const = 0;\n" + "};\n" + ) + (tmp_path / "circle.cppm").write_text( + "export module Circle;\n" + "import Shape;\n" + "export class Circle : public Shape {\n" + " int r;\n" + "public:\n" + " Circle(int r) : r(r) {}\n" + " int area() const override { return 3 * r * r; }\n" + "};\n" + ) + _write_cdb(tmp_path, ["shape.cppm", "circle.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "circle.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Save triggers recompilation (close/reopen with new content) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_save_recompile(client, tmp_path): + """Closing and reopening a modified module file should recompile without errors.""" + (tmp_path / "leaf.cppm").write_text( + "export module Leaf;\nexport int leaf() { return 1; }\n" + ) + (tmp_path / "mid.cppm").write_text( + "export module Mid;\nimport Leaf;\nexport int mid() { return leaf() + 1; }\n" + ) + _write_cdb(tmp_path, ["leaf.cppm", "mid.cppm"]) + await _init(client, tmp_path) + + # Open and compile Mid (which triggers Leaf PCM build). + mid_uri, _ = await _open_and_wait(client, tmp_path, "mid.cppm") + diags = client.diagnostics.get(mid_uri, []) + assert len(diags) == 0 + + # Open Leaf and wait for its initial compilation. + leaf_uri, _ = _open(client, tmp_path, "leaf.cppm") + event = client.wait_for_diagnostics(leaf_uri) + await asyncio.wait_for(event.wait(), timeout=60.0) + + # Close Leaf, modify on disk, and reopen with new content. + client.text_document_did_close( + DidCloseTextDocumentParams(text_document=TextDocumentIdentifier(uri=leaf_uri)) + ) + + new_content = "export module Leaf;\nexport int leaf() { return 100; }\n" + (tmp_path / "leaf.cppm").write_text(new_content) + + # Reopen with new content triggers compilation. + event = client.wait_for_diagnostics(leaf_uri) + client.text_document_did_open( + DidOpenTextDocumentParams( + text_document=TextDocumentItem( + uri=leaf_uri, language_id="cpp", version=1, text=new_content + ) + ) + ) + await asyncio.wait_for(event.wait(), timeout=60.0) + + # Should still compile without errors after change. + diags = client.diagnostics.get(leaf_uri, []) + assert len(diags) == 0, f"Expected no diagnostics after save, got: {diags}" + + +# --------------------------------------------------------------------------- +# Compilation failure (undefined symbol in module) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_module_compile_error(client, tmp_path): + """A module with an error should produce diagnostics.""" + (tmp_path / "good.cppm").write_text( + "export module Good;\nexport int good() { return 1; }\n" + ) + (tmp_path / "bad.cppm").write_text( + "export module Bad;\n" + "import Good;\n" + "export int bad() { return UNDEFINED_SYMBOL; }\n" + ) + _write_cdb(tmp_path, ["good.cppm", "bad.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "bad.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) > 0, "Expected diagnostics for undefined symbol" + + +# --------------------------------------------------------------------------- +# Deep chain (5 modules) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_deep_chain(client, tmp_path): + """A 5-level module chain should compile correctly.""" + (tmp_path / "m1.cppm").write_text( + "export module M1;\nexport int f1() { return 1; }\n" + ) + (tmp_path / "m2.cppm").write_text( + "export module M2;\nimport M1;\nexport int f2() { return f1() + 1; }\n" + ) + (tmp_path / "m3.cppm").write_text( + "export module M3;\nimport M2;\nexport int f3() { return f2() + 1; }\n" + ) + (tmp_path / "m4.cppm").write_text( + "export module M4;\nimport M3;\nexport int f4() { return f3() + 1; }\n" + ) + (tmp_path / "m5.cppm").write_text( + "export module M5;\nimport M4;\nexport int f5() { return f4() + 1; }\n" + ) + _write_cdb(tmp_path, ["m1.cppm", "m2.cppm", "m3.cppm", "m4.cppm", "m5.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "m5.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Partition with GMF (#include inside global module fragment of partition) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_partition_with_gmf(client, tmp_path): + """Partition with GMF (#include) should compile correctly.""" + (tmp_path / "config.h").write_text("#define MAX_SIZE 100\n") + (tmp_path / "part_cfg.cppm").write_text( + "module;\n" + '#include "config.h"\n' + "export module Cfg:Limits;\n" + "export constexpr int max_size = MAX_SIZE;\n" + ) + (tmp_path / "cfg.cppm").write_text("export module Cfg;\nexport import :Limits;\n") + _write_cdb_entries( + tmp_path, + [ + ("part_cfg.cppm", ["-I", str(tmp_path)]), + ("cfg.cppm", []), + ], + ) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "cfg.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Cross-module partition + external import +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_partition_with_external_import(client, tmp_path): + """Partition importing an external module should compile correctly.""" + (tmp_path / "ext.cppm").write_text( + "export module Ext;\nexport int ext_val() { return 99; }\n" + ) + (tmp_path / "part.cppm").write_text( + "export module App:Core;\n" + "import Ext;\n" + "export int core_fn() { return ext_val() + 1; }\n" + ) + (tmp_path / "app.cppm").write_text("export module App;\nexport import :Core;\n") + _write_cdb(tmp_path, ["ext.cppm", "part.cppm", "app.cppm"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "app.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Hover on imported symbol (feature request after module compilation) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_hover_on_imported_symbol(client, tmp_path): + """Hover on a symbol imported from a module should return info.""" + (tmp_path / "defs.cppm").write_text( + "export module Defs;\nexport int magic_number() { return 42; }\n" + ) + (tmp_path / "use.cpp").write_text( + "import Defs;\nint main() { return magic_number(); }\n" + ) + _write_cdb(tmp_path, ["defs.cppm", "use.cpp"]) + await _init(client, tmp_path) + + uri, _ = await _open_and_wait(client, tmp_path, "use.cpp") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + # Hover on 'magic_number' (line 1, character 22 = inside 'magic_number()') + hover = await client.text_document_hover_async( + HoverParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=1, character=22), + ) + ) + assert hover is not None, "Hover on imported symbol should return info" + assert hover.contents is not None diff --git a/tests/unit/server/compile_graph_integration_tests.cpp b/tests/unit/server/compile_graph_integration_tests.cpp new file mode 100644 index 000000000..b3eddfdc5 --- /dev/null +++ b/tests/unit/server/compile_graph_integration_tests.cpp @@ -0,0 +1,1228 @@ +#include "test/temp_dir.h" +#include "test/test.h" +#include "command/command.h" +#include "compile/compilation.h" +#include "server/compile_graph.h" +#include "support/path_pool.h" +#include "syntax/dependency_graph.h" +#include "syntax/scan.h" + +namespace clice::testing { +namespace { + +namespace et = eventide; + +// ============================================================================ +// Helpers (same CDB-building pattern as dependency_graph_tests.cpp) +// ============================================================================ + +struct CDBEntry { + llvm::StringRef dir; + std::string file; + std::vector extra_args; +}; + +std::string json_escape_ig(llvm::StringRef s) { + std::string result; + result.reserve(s.size()); + for(char c: s) { + if(c == '\\' || c == '"') { + result += '\\'; + } + result += c; + } + return result; +} + +std::string build_cdb_json(llvm::ArrayRef entries) { + std::string json = "[\n"; + for(std::size_t i = 0; i < entries.size(); ++i) { + auto& e = entries[i]; + if(i > 0) { + json += ",\n"; + } + json += R"( {"directory": ")"; + json += json_escape_ig(e.dir); + json += R"(", "file": ")"; + json += json_escape_ig(e.file); + json += R"(", "arguments": ["clang++", "-std=c++20")"; + for(auto& arg: e.extra_args) { + json += R"(, ")"; + json += json_escape_ig(arg); + json += R"(")"; + } + json += R"(, ")"; + json += json_escape_ig(e.file); + json += R"("]})"; + } + json += "\n]"; + return json; +} + +void write_cdb(TempDir& tmp, CompilationDatabase& cdb, llvm::StringRef json_content) { + tmp.touch("compile_commands.json", json_content); + cdb.load(tmp.path("compile_commands.json")); +} + +/// Build a dispatch_fn that compiles PCMs in-process (no workers). +/// Clang requires ALL transitive PCM deps (not just direct imports) +/// in PrebuiltModuleFiles, so we pass every available PCM. +CompileGraph::dispatch_fn make_dispatch(CompilationDatabase& cdb, + PathPool& pool, + DependencyGraph& graph, + llvm::DenseMap& pcm_paths) { + return [&](std::uint32_t path_id) -> et::task { + auto file_path = pool.resolve(path_id); + auto results = cdb.lookup(file_path, {.query_toolchain = true, .suppress_logging = true}); + if(results.empty()) { + co_return false; + } + + CompilationParams cp; + cp.kind = CompilationKind::ModuleInterface; + cp.directory = results[0].directory.str(); + for(auto* arg: results[0].arguments) { + cp.arguments.push_back(arg); + } + + // Fill ALL available PCM paths (clang needs transitive deps too). + for(auto& [pid, pcm_path]: pcm_paths) { + for(auto& [mod_name, mod_ids]: graph.modules()) { + if(llvm::find(mod_ids, pid) != mod_ids.end()) { + cp.pcms.try_emplace(mod_name, pcm_path); + break; + } + } + } + + auto tmp = fs::createTemporaryFile("test-pcm", "pcm"); + if(!tmp) { + co_return false; + } + cp.output_file = *tmp; + + PCMInfo info; + auto unit = compile(cp, info); + + if(unit.completed()) { + pcm_paths[path_id] = std::string(cp.output_file); + co_return true; + } + co_return false; + }; +} + +/// Build a resolve_fn that lazily scans module files for imports. +CompileGraph::resolve_fn make_resolver(CompilationDatabase& cdb, + PathPool& pool, + DependencyGraph& graph) { + return [&](std::uint32_t path_id) -> llvm::SmallVector { + auto file_path = pool.resolve(path_id); + auto results = cdb.lookup(file_path, {.query_toolchain = true, .suppress_logging = true}); + if(results.empty()) { + return {}; + } + + auto scan_result = scan_precise(results[0].arguments, results[0].directory); + + llvm::SmallVector deps; + for(auto& mod_name: scan_result.modules) { + auto mod_ids = graph.lookup_module(mod_name); + if(!mod_ids.empty()) { + deps.push_back(mod_ids[0]); + } + } + return deps; + }; +} + +/// Helper to set up infra, compile a module, and verify all PCMs are produced. +struct ModuleTestEnv { + TempDir tmp; + CompilationDatabase cdb; + PathPool pool; + DependencyGraph graph; + llvm::DenseMap pcm_paths; + + void setup(llvm::ArrayRef entries, llvm::StringRef json) { + write_cdb(tmp, cdb, json); + scan_dependency_graph(cdb, pool, graph); + } + + std::uint32_t lookup(llvm::StringRef mod_name) { + auto ids = graph.lookup_module(mod_name); + return ids.empty() ? UINT32_MAX : ids[0]; + } +}; + +TEST_SUITE(CompileGraphIntegration) { + +// ============================================================================ +// Basic module interface units +// ============================================================================ + +TEST_CASE(SingleModuleNoDeps) { + ModuleTestEnv env; + env.tmp.touch("mod_a.cppm", "export module A;\n" "export int foo() { return 42; }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("mod_a.cppm"), {}} + }); + env.setup({}, json); + + ASSERT_FALSE(env.graph.lookup_module("A").empty()); + auto pid_a = env.lookup("A"); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_a]() -> et::task<> { + auto result = co_await cg.compile(pid_a).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_TRUE(env.pcm_paths.count(pid_a)); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(ChainedModules) { + ModuleTestEnv env; + env.tmp.touch("mod_a.cppm", "export module A;\n" "export int foo() { return 42; }\n"); + env.tmp.touch("mod_b.cppm", + "export module B;\n" + "import A;\n" + "export int bar() { return foo() + 1; }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("mod_a.cppm"), {}}, + {env.tmp.root, env.tmp.path("mod_b.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_a = env.lookup("A"); + auto pid_b = env.lookup("B"); + ASSERT_NE(pid_a, UINT32_MAX); + ASSERT_NE(pid_b, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_a, pid_b]() -> et::task<> { + auto result = co_await cg.compile(pid_b).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_TRUE(env.pcm_paths.count(pid_a)); + EXPECT_TRUE(env.pcm_paths.count(pid_b)); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(DiamondModules) { + ModuleTestEnv env; + env.tmp.touch("mod_base.cppm", + "export module Base;\n" "export int base_val() { return 10; }\n"); + env.tmp.touch("mod_left.cppm", + "export module Left;\n" + "import Base;\n" + "export int left_val() { return base_val() + 1; }\n"); + env.tmp.touch("mod_right.cppm", + "export module Right;\n" + "import Base;\n" + "export int right_val() { return base_val() + 2; }\n"); + env.tmp.touch("mod_top.cppm", + "export module Top;\n" + "import Left;\n" + "import Right;\n" + "export int top_val() { return left_val() + right_val(); }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("mod_base.cppm"), {}}, + {env.tmp.root, env.tmp.path("mod_left.cppm"), {}}, + {env.tmp.root, env.tmp.path("mod_right.cppm"), {}}, + {env.tmp.root, env.tmp.path("mod_top.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_top = env.lookup("Top"); + ASSERT_NE(pid_top, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_top]() -> et::task<> { + auto result = co_await cg.compile(pid_top).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(env.pcm_paths.size(), 4u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Dotted module names +// ============================================================================ + +TEST_CASE(DottedModuleName) { + ModuleTestEnv env; + env.tmp.touch("io.cppm", "export module my.io;\n" "export void print() {}\n"); + env.tmp.touch("app.cppm", + "export module my.app;\n" + "import my.io;\n" + "export void run() { print(); }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("io.cppm"), {}}, + {env.tmp.root, env.tmp.path("app.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_app = env.lookup("my.app"); + ASSERT_NE(pid_app, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_app]() -> et::task<> { + auto result = co_await cg.compile(pid_app).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(env.pcm_paths.size(), 2u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Re-export (export import) +// ============================================================================ + +TEST_CASE(ReExport) { + ModuleTestEnv env; + env.tmp.touch("core.cppm", "export module Core;\n" "export int core_fn() { return 1; }\n"); + env.tmp.touch("wrapper.cppm", + "export module Wrapper;\n" + "export import Core;\n" + "export int wrap_fn() { return core_fn() + 10; }\n"); + env.tmp.touch("user.cppm", + "export module User;\n" + "import Wrapper;\n" + "// core_fn() is accessible via re-export.\n" + "export int use_fn() { return core_fn() + wrap_fn(); }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("core.cppm"), {}}, + {env.tmp.root, env.tmp.path("wrapper.cppm"), {}}, + {env.tmp.root, env.tmp.path("user.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_user = env.lookup("User"); + ASSERT_NE(pid_user, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_user]() -> et::task<> { + auto result = co_await cg.compile(pid_user).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(env.pcm_paths.size(), 3u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Export block syntax +// ============================================================================ + +TEST_CASE(ExportBlock) { + ModuleTestEnv env; + env.tmp.touch("block.cppm", + "export module Block;\n" + "export {\n" + " int alpha() { return 1; }\n" + " int beta() { return 2; }\n" + " namespace ns {\n" + " int gamma() { return 3; }\n" + " }\n" + "}\n"); + env.tmp.touch("consumer.cppm", + "export module Consumer;\n" + "import Block;\n" + "export int total() { return alpha() + beta() + ns::gamma(); }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("block.cppm"), {}}, + {env.tmp.root, env.tmp.path("consumer.cppm"), {}}, + }); + env.setup({}, json); + + auto pid = env.lookup("Consumer"); + ASSERT_NE(pid, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid]() -> et::task<> { + auto result = co_await cg.compile(pid).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(env.pcm_paths.size(), 2u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Global module fragment +// ============================================================================ + +TEST_CASE(GlobalModuleFragment) { + ModuleTestEnv env; + env.tmp.touch("legacy.h", "inline int legacy_fn() { return 99; }\n"); + env.tmp.touch("gmf.cppm", + "module;\n" + "#include \"legacy.h\"\n" + "export module GMF;\n" + "export int wrapped() { return legacy_fn(); }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("gmf.cppm"), {"-I", env.tmp.path(".")}}, + }); + env.setup({}, json); + + auto pid = env.lookup("GMF"); + ASSERT_NE(pid, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid]() -> et::task<> { + auto result = co_await cg.compile(pid).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_TRUE(env.pcm_paths.count(pid)); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Private module fragment +// ============================================================================ + +TEST_CASE(PrivateModuleFragment) { + ModuleTestEnv env; + env.tmp.touch("priv.cppm", + "export module Priv;\n" + "export int public_fn();\n" + "module : private;\n" + "int public_fn() { return 42; }\n" + "int private_helper() { return 7; }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("priv.cppm"), {}}, + }); + env.setup({}, json); + + auto pid = env.lookup("Priv"); + ASSERT_NE(pid, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid]() -> et::task<> { + auto result = co_await cg.compile(pid).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_TRUE(env.pcm_paths.count(pid)); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Module partitions — interface partition +// ============================================================================ + +TEST_CASE(PartitionInterface) { + ModuleTestEnv env; + // Partition interface unit. + env.tmp.touch("part.cppm", "export module M:Part;\n" "export int part_fn() { return 5; }\n"); + // Primary module interface re-exports the partition. + env.tmp.touch("primary.cppm", + "export module M;\n" + "export import :Part;\n" + "export int primary_fn() { return part_fn() + 1; }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("part.cppm"), {}}, + {env.tmp.root, env.tmp.path("primary.cppm"), {}}, + }); + env.setup({}, json); + + // The partition is registered as "M:Part", primary as "M". + auto pid_m = env.lookup("M"); + ASSERT_NE(pid_m, UINT32_MAX); + ASSERT_NE(env.lookup("M:Part"), UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_m]() -> et::task<> { + auto result = co_await cg.compile(pid_m).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + // Both partition and primary should be compiled. + EXPECT_EQ(env.pcm_paths.size(), 2u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Multiple partitions +// ============================================================================ + +TEST_CASE(MultiplePartitions) { + ModuleTestEnv env; + env.tmp.touch("part_a.cppm", "export module Lib:A;\n" "export int a_fn() { return 1; }\n"); + env.tmp.touch("part_b.cppm", "export module Lib:B;\n" "export int b_fn() { return 2; }\n"); + env.tmp.touch("lib.cppm", + "export module Lib;\n" + "export import :A;\n" + "export import :B;\n" + "export int lib_fn() { return a_fn() + b_fn(); }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("part_a.cppm"), {}}, + {env.tmp.root, env.tmp.path("part_b.cppm"), {}}, + {env.tmp.root, env.tmp.path("lib.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_lib = env.lookup("Lib"); + ASSERT_NE(pid_lib, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_lib]() -> et::task<> { + auto result = co_await cg.compile(pid_lib).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + // Lib:A, Lib:B, and Lib. + EXPECT_EQ(env.pcm_paths.size(), 3u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Partition importing another partition (within same module) +// ============================================================================ + +TEST_CASE(PartitionChain) { + ModuleTestEnv env; + env.tmp.touch("types.cppm", + "export module Sys:Types;\n" "export struct Config { int value = 0; };\n"); + env.tmp.touch("core.cppm", + "export module Sys:Core;\n" + "import :Types;\n" + "export Config make_config() { return {42}; }\n"); + env.tmp.touch("sys.cppm", + "export module Sys;\n" + "export import :Types;\n" + "export import :Core;\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("types.cppm"), {}}, + {env.tmp.root, env.tmp.path("core.cppm"), {}}, + {env.tmp.root, env.tmp.path("sys.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_sys = env.lookup("Sys"); + ASSERT_NE(pid_sys, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_sys]() -> et::task<> { + auto result = co_await cg.compile(pid_sys).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + // Sys:Types, Sys:Core, Sys. + EXPECT_EQ(env.pcm_paths.size(), 3u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Module with exported namespace +// ============================================================================ + +TEST_CASE(ExportNamespace) { + ModuleTestEnv env; + env.tmp.touch("ns.cppm", + "export module NS;\n" + "export namespace math {\n" + " int add(int a, int b) { return a + b; }\n" + " int mul(int a, int b) { return a * b; }\n" + "}\n"); + env.tmp.touch("calc.cppm", + "export module Calc;\n" + "import NS;\n" + "export int compute() { return math::add(3, math::mul(4, 5)); }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("ns.cppm"), {}}, + {env.tmp.root, env.tmp.path("calc.cppm"), {}}, + }); + env.setup({}, json); + + auto pid = env.lookup("Calc"); + ASSERT_NE(pid, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid]() -> et::task<> { + auto result = co_await cg.compile(pid).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(env.pcm_paths.size(), 2u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// GMF with include + module import +// ============================================================================ + +TEST_CASE(GMFWithImport) { + ModuleTestEnv env; + env.tmp.touch("util.h", "inline int util_helper() { return 7; }\n"); + env.tmp.touch("base.cppm", "export module Base;\n" "export int base() { return 100; }\n"); + env.tmp.touch("combined.cppm", + "module;\n" + "#include \"util.h\"\n" + "export module Combined;\n" + "import Base;\n" + "export int combined() { return base() + util_helper(); }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("base.cppm"), {} }, + {env.tmp.root, env.tmp.path("combined.cppm"), {"-I", env.tmp.path(".")}}, + }); + env.setup({}, json); + + auto pid = env.lookup("Combined"); + ASSERT_NE(pid, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid]() -> et::task<> { + auto result = co_await cg.compile(pid).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(env.pcm_paths.size(), 2u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Deep chain (5 modules) +// ============================================================================ + +TEST_CASE(DeepChain) { + ModuleTestEnv env; + env.tmp.touch("m1.cppm", "export module M1;\n" "export int f1() { return 1; }\n"); + env.tmp.touch("m2.cppm", + "export module M2;\n" + "import M1;\n" + "export int f2() { return f1() + 1; }\n"); + env.tmp.touch("m3.cppm", + "export module M3;\n" + "import M2;\n" + "export int f3() { return f2() + 1; }\n"); + env.tmp.touch("m4.cppm", + "export module M4;\n" + "import M3;\n" + "export int f4() { return f3() + 1; }\n"); + env.tmp.touch("m5.cppm", + "export module M5;\n" + "import M4;\n" + "export int f5() { return f4() + 1; }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("m1.cppm"), {}}, + {env.tmp.root, env.tmp.path("m2.cppm"), {}}, + {env.tmp.root, env.tmp.path("m3.cppm"), {}}, + {env.tmp.root, env.tmp.path("m4.cppm"), {}}, + {env.tmp.root, env.tmp.path("m5.cppm"), {}}, + }); + env.setup({}, json); + + auto pid = env.lookup("M5"); + ASSERT_NE(pid, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid]() -> et::task<> { + auto result = co_await cg.compile(pid).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(env.pcm_paths.size(), 5u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Multiple independent modules (no shared deps) +// ============================================================================ + +TEST_CASE(IndependentModules) { + ModuleTestEnv env; + env.tmp.touch("x.cppm", "export module X;\n" "export int x() { return 1; }\n"); + env.tmp.touch("y.cppm", "export module Y;\n" "export int y() { return 2; }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("x.cppm"), {}}, + {env.tmp.root, env.tmp.path("y.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_x = env.lookup("X"); + auto pid_y = env.lookup("Y"); + ASSERT_NE(pid_x, UINT32_MAX); + ASSERT_NE(pid_y, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_x, pid_y]() -> et::task<> { + auto r1 = co_await cg.compile(pid_x).catch_cancel(); + EXPECT_TRUE(r1.has_value() && *r1); + auto r2 = co_await cg.compile(pid_y).catch_cancel(); + EXPECT_TRUE(r2.has_value() && *r2); + EXPECT_EQ(env.pcm_paths.size(), 2u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Module with template exports +// ============================================================================ + +TEST_CASE(TemplateExport) { + ModuleTestEnv env; + env.tmp.touch("tmpl.cppm", + "export module Tmpl;\n" + "export template\n" + "T identity(T x) { return x; }\n" + "export template\n" + "auto pair_sum(T a, U b) { return a + b; }\n"); + env.tmp.touch("use_tmpl.cppm", + "export module UseTmpl;\n" + "import Tmpl;\n" + "export int test() { return identity(42) + pair_sum(1, 2); }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("tmpl.cppm"), {}}, + {env.tmp.root, env.tmp.path("use_tmpl.cppm"), {}}, + }); + env.setup({}, json); + + auto pid = env.lookup("UseTmpl"); + ASSERT_NE(pid, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid]() -> et::task<> { + auto result = co_await cg.compile(pid).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(env.pcm_paths.size(), 2u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Module with class export and inheritance across modules +// ============================================================================ + +TEST_CASE(ClassExportAndInheritance) { + ModuleTestEnv env; + env.tmp.touch("shape.cppm", + "export module Shape;\n" + "export class Shape {\n" + "public:\n" + " virtual ~Shape() = default;\n" + " virtual int area() const = 0;\n" + "};\n"); + env.tmp.touch("circle.cppm", + "export module Circle;\n" + "import Shape;\n" + "export class Circle : public Shape {\n" + " int r;\n" + "public:\n" + " Circle(int r) : r(r) {}\n" + " int area() const override { return 3 * r * r; }\n" + "};\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("shape.cppm"), {}}, + {env.tmp.root, env.tmp.path("circle.cppm"), {}}, + }); + env.setup({}, json); + + auto pid = env.lookup("Circle"); + ASSERT_NE(pid, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid]() -> et::task<> { + auto result = co_await cg.compile(pid).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(env.pcm_paths.size(), 2u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Recompile after update (invalidation + recompile) +// ============================================================================ + +TEST_CASE(RecompileAfterUpdate) { + ModuleTestEnv env; + env.tmp.touch("leaf.cppm", "export module Leaf;\n" "export int leaf() { return 1; }\n"); + env.tmp.touch("mid.cppm", + "export module Mid;\n" + "import Leaf;\n" + "export int mid() { return leaf() + 1; }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("leaf.cppm"), {}}, + {env.tmp.root, env.tmp.path("mid.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_leaf = env.lookup("Leaf"); + auto pid_mid = env.lookup("Mid"); + ASSERT_NE(pid_leaf, UINT32_MAX); + ASSERT_NE(pid_mid, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_leaf, pid_mid]() -> et::task<> { + // First compile. + auto r1 = co_await cg.compile(pid_mid).catch_cancel(); + EXPECT_TRUE(r1.has_value() && *r1); + EXPECT_EQ(env.pcm_paths.size(), 2u); + EXPECT_FALSE(cg.is_dirty(pid_leaf)); + EXPECT_FALSE(cg.is_dirty(pid_mid)); + + // Simulate editing Leaf — should cascade to Mid. + cg.update(pid_leaf); + EXPECT_TRUE(cg.is_dirty(pid_leaf)); + EXPECT_TRUE(cg.is_dirty(pid_mid)); + + // Recompile. + auto r2 = co_await cg.compile(pid_mid).catch_cancel(); + EXPECT_TRUE(r2.has_value() && *r2); + EXPECT_FALSE(cg.is_dirty(pid_leaf)); + EXPECT_FALSE(cg.is_dirty(pid_mid)); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Partition with GMF (#include inside global module fragment of partition) +// ============================================================================ + +TEST_CASE(PartitionWithGMF) { + ModuleTestEnv env; + env.tmp.touch("config.h", "#define MAX_SIZE 100\n"); + env.tmp.touch("part_cfg.cppm", + "module;\n" + "#include \"config.h\"\n" + "export module Cfg:Limits;\n" + "export constexpr int max_size = MAX_SIZE;\n"); + env.tmp.touch("cfg.cppm", "export module Cfg;\n" "export import :Limits;\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("part_cfg.cppm"), {"-I", env.tmp.path(".")}}, + {env.tmp.root, env.tmp.path("cfg.cppm"), {} }, + }); + env.setup({}, json); + + auto pid = env.lookup("Cfg"); + ASSERT_NE(pid, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid]() -> et::task<> { + auto result = co_await cg.compile(pid).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(env.pcm_paths.size(), 2u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Cross-module partition + external import +// ============================================================================ + +TEST_CASE(PartitionWithExternalImport) { + ModuleTestEnv env; + // External module. + env.tmp.touch("ext.cppm", "export module Ext;\n" "export int ext_val() { return 99; }\n"); + // Partition that imports the external module. + env.tmp.touch("part.cppm", + "export module App:Core;\n" + "import Ext;\n" + "export int core_fn() { return ext_val() + 1; }\n"); + // Primary module interface. + env.tmp.touch("app.cppm", "export module App;\n" "export import :Core;\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("ext.cppm"), {}}, + {env.tmp.root, env.tmp.path("part.cppm"), {}}, + {env.tmp.root, env.tmp.path("app.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_app = env.lookup("App"); + ASSERT_NE(pid_app, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_app]() -> et::task<> { + auto result = co_await cg.compile(pid_app).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + // Ext, App:Core, App. + EXPECT_EQ(env.pcm_paths.size(), 3u); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Diamond update cascade + recompile +// ============================================================================ + +TEST_CASE(DiamondUpdateCascade) { + ModuleTestEnv env; + env.tmp.touch("mod_base.cppm", + "export module Base;\n" "export int base_val() { return 10; }\n"); + env.tmp.touch("mod_left.cppm", + "export module Left;\n" + "import Base;\n" + "export int left_val() { return base_val() + 1; }\n"); + env.tmp.touch("mod_right.cppm", + "export module Right;\n" + "import Base;\n" + "export int right_val() { return base_val() + 2; }\n"); + env.tmp.touch("mod_top.cppm", + "export module Top;\n" + "import Left;\n" + "import Right;\n" + "export int top_val() { return left_val() + right_val(); }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("mod_base.cppm"), {}}, + {env.tmp.root, env.tmp.path("mod_left.cppm"), {}}, + {env.tmp.root, env.tmp.path("mod_right.cppm"), {}}, + {env.tmp.root, env.tmp.path("mod_top.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_base = env.lookup("Base"); + auto pid_left = env.lookup("Left"); + auto pid_right = env.lookup("Right"); + auto pid_top = env.lookup("Top"); + ASSERT_NE(pid_base, UINT32_MAX); + ASSERT_NE(pid_top, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_base, pid_left, pid_right, pid_top]() -> et::task<> { + // Initial compile. + auto r1 = co_await cg.compile(pid_top).catch_cancel(); + EXPECT_TRUE(r1.has_value() && *r1); + EXPECT_EQ(env.pcm_paths.size(), 4u); + + // Save old PCM paths. + auto old_base_pcm = env.pcm_paths[pid_base]; + + // Update base: should cascade to Left, Right, Top. + auto dirtied = cg.update(pid_base); + EXPECT_TRUE(cg.is_dirty(pid_base)); + EXPECT_TRUE(cg.is_dirty(pid_left)); + EXPECT_TRUE(cg.is_dirty(pid_right)); + EXPECT_TRUE(cg.is_dirty(pid_top)); + + // Simulate MasterServer: erase stale PCMs for all dirtied nodes. + for(auto id: dirtied) { + env.pcm_paths.erase(id); + } + EXPECT_EQ(env.pcm_paths.size(), 0u); + + // Recompile. + auto r2 = co_await cg.compile(pid_top).catch_cancel(); + EXPECT_TRUE(r2.has_value() && *r2); + EXPECT_EQ(env.pcm_paths.size(), 4u); + // PCM path should have changed (new temp file). + EXPECT_NE(env.pcm_paths[pid_base], old_base_pcm); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Verify resolve_fn is re-invoked after update (resolved=false) +// ============================================================================ + +TEST_CASE(ReResolveAfterUpdate) { + ModuleTestEnv env; + // Start with Mid importing Leaf. + env.tmp.touch("leaf.cppm", "export module Leaf;\n" "export int leaf() { return 1; }\n"); + env.tmp.touch("extra.cppm", "export module Extra;\n" "export int extra() { return 99; }\n"); + env.tmp.touch("mid.cppm", + "export module Mid;\n" + "import Leaf;\n" + "export int mid() { return leaf() + 1; }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("leaf.cppm"), {}}, + {env.tmp.root, env.tmp.path("extra.cppm"), {}}, + {env.tmp.root, env.tmp.path("mid.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_leaf = env.lookup("Leaf"); + auto pid_extra = env.lookup("Extra"); + auto pid_mid = env.lookup("Mid"); + ASSERT_NE(pid_mid, UINT32_MAX); + ASSERT_NE(pid_extra, UINT32_MAX); + + int resolve_count = 0; + auto counting_resolver = [&](std::uint32_t path_id) -> llvm::SmallVector { + if(path_id == pid_mid) { + resolve_count++; + } + // Delegate to the standard resolver. + auto file_path = env.pool.resolve(path_id); + auto results = + env.cdb.lookup(file_path, {.query_toolchain = true, .suppress_logging = true}); + if(results.empty()) { + return {}; + } + auto scan_result = scan_precise(results[0].arguments, results[0].directory); + llvm::SmallVector deps; + for(auto& mod_name: scan_result.modules) { + auto mod_ids = env.graph.lookup_module(mod_name); + if(!mod_ids.empty()) { + deps.push_back(mod_ids[0]); + } + } + return deps; + }; + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + std::move(counting_resolver)); + + et::event_loop loop; + auto test = [this, &cg, &env, &resolve_count, pid_mid]() -> et::task<> { + // First compile: resolve_fn called once for Mid. + auto r1 = co_await cg.compile(pid_mid).catch_cancel(); + EXPECT_TRUE(r1.has_value() && *r1); + EXPECT_EQ(resolve_count, 1); + + // Update Mid: resets resolved. + cg.update(pid_mid); + + // Recompile: resolve_fn should be called again. + auto r2 = co_await cg.compile(pid_mid).catch_cancel(); + EXPECT_TRUE(r2.has_value() && *r2); + EXPECT_EQ(resolve_count, 2); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Compilation failure propagation (real clang error) +// ============================================================================ + +TEST_CASE(CompileFailurePropagation) { + ModuleTestEnv env; + // Good module. + env.tmp.touch("good.cppm", "export module Good;\n" "export int good() { return 1; }\n"); + // Bad module with syntax error. + env.tmp.touch("bad.cppm", + "export module Bad;\n" + "import Good;\n" + "export int bad() { return UNDEFINED_SYMBOL; }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("good.cppm"), {}}, + {env.tmp.root, env.tmp.path("bad.cppm"), {}}, + }); + env.setup({}, json); + + auto pid_bad = env.lookup("Bad"); + ASSERT_NE(pid_bad, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_bad]() -> et::task<> { + auto result = co_await cg.compile(pid_bad).catch_cancel(); + EXPECT_TRUE(result.has_value()); + // Compilation should fail due to undefined symbol. + EXPECT_FALSE(*result); + // Good module should still have been compiled successfully. + auto pid_good = env.lookup("Good"); + EXPECT_TRUE(env.pcm_paths.count(pid_good)); + // Bad module should NOT have a PCM. + EXPECT_FALSE(env.pcm_paths.count(pid_bad)); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +// ============================================================================ +// Module implementation unit (consumes PCM, doesn't produce one) +// ============================================================================ + +TEST_CASE(ModuleImplementationUnit) { + ModuleTestEnv env; + // Module interface unit — produces PCM. + env.tmp.touch("iface.cppm", "export module Greeter;\n" "export const char* greet();\n"); + // Module implementation unit — consumes PCM, no export. + env.tmp.touch("impl.cpp", "module Greeter;\n" "const char* greet() { return \"hello\"; }\n"); + + auto json = build_cdb_json({ + {env.tmp.root, env.tmp.path("iface.cppm"), {}}, + {env.tmp.root, env.tmp.path("impl.cpp"), {}}, + }); + env.setup({}, json); + + auto pid_iface = env.lookup("Greeter"); + ASSERT_NE(pid_iface, UINT32_MAX); + + CompileGraph cg(make_dispatch(env.cdb, env.pool, env.graph, env.pcm_paths), + make_resolver(env.cdb, env.pool, env.graph)); + + et::event_loop loop; + auto test = [this, &cg, &env, pid_iface]() -> et::task<> { + // Build the interface PCM via CompileGraph. + auto r1 = co_await cg.compile(pid_iface).catch_cancel(); + EXPECT_TRUE(r1.has_value() && *r1); + EXPECT_TRUE(env.pcm_paths.count(pid_iface)); + + // Now compile the implementation unit as Content (like a stateful worker would). + auto impl_path = env.tmp.path("impl.cpp"); + auto results = + env.cdb.lookup(impl_path, {.query_toolchain = true, .suppress_logging = true}); + CO_ASSERT_FALSE(results.empty()); + + CompilationParams cp; + cp.kind = CompilationKind::Content; + cp.directory = results[0].directory.str(); + for(auto* arg: results[0].arguments) { + cp.arguments.push_back(arg); + } + // Pass the built PCM so clang can resolve `module Greeter;`. + for(auto& [pid, pcm_path]: env.pcm_paths) { + for(auto& [mod_name, mod_ids]: env.graph.modules()) { + if(llvm::find(mod_ids, pid) != mod_ids.end()) { + cp.pcms.try_emplace(mod_name, pcm_path); + break; + } + } + } + + auto unit = compile(cp); + EXPECT_TRUE(unit.completed()); + }; + auto t = test(); + loop.schedule(t); + loop.run(); +} + +}; // TEST_SUITE(CompileGraphIntegration) + +} // namespace +} // namespace clice::testing diff --git a/tests/unit/server/compile_graph_tests.cpp b/tests/unit/server/compile_graph_tests.cpp index 19d23b4a6..627c48b9b 100644 --- a/tests/unit/server/compile_graph_tests.cpp +++ b/tests/unit/server/compile_graph_tests.cpp @@ -6,14 +6,31 @@ namespace { namespace et = eventide; -/// Create a dispatch function that always succeeds instantly. +/// A resolve_fn that always returns no dependencies. +inline CompileGraph::resolve_fn no_deps() { + return [](std::uint32_t) -> llvm::SmallVector { + return {}; + }; +} + +/// A resolve_fn backed by a static adjacency map. +inline CompileGraph::resolve_fn + static_resolver(llvm::DenseMap> adj) { + return [adj = std::move(adj)](std::uint32_t path_id) -> llvm::SmallVector { + auto it = adj.find(path_id); + if(it != adj.end()) { + return it->second; + } + return {}; + }; +} + inline CompileGraph::dispatch_fn instant_dispatch() { return [](std::uint32_t) -> et::task { co_return true; }; } -/// Create a dispatch function that records which units were compiled. inline CompileGraph::dispatch_fn tracking_dispatch(std::vector& compiled) { return [&compiled](std::uint32_t path_id) -> et::task { compiled.push_back(path_id); @@ -21,135 +38,151 @@ inline CompileGraph::dispatch_fn tracking_dispatch(std::vector& c }; } -/// Create a dispatch function that always fails. inline CompileGraph::dispatch_fn failing_dispatch() { return [](std::uint32_t) -> et::task { co_return false; }; } -/// Helper: runs an async test. Creates event_loop + CompileGraph, -/// passes them to the test body, then the loop drains naturally. -template -void run_test(CompileGraph::dispatch_fn dispatch, F&& body) { +TEST_SUITE(CompileGraph) { + +TEST_CASE(CompileNoDeps) { et::event_loop loop; - CompileGraph graph(loop, std::move(dispatch)); + std::vector compiled; + CompileGraph graph(tracking_dispatch(compiled), no_deps()); - auto wrapper = [&]() -> et::task<> { - co_await body(graph); + auto test = [this, &graph, &compiled]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(compiled.size(), 1u); + EXPECT_EQ(compiled[0], 1u); + EXPECT_FALSE(graph.is_dirty(1)); }; - auto t = wrapper(); + auto t = test(); loop.schedule(t); loop.run(); } -TEST_SUITE(CompileGraph) { - -TEST_CASE(RegisterUnit) { - run_test(instant_dispatch(), [this](CompileGraph& graph) -> et::task<> { - graph.register_unit(1, {2, 3}); - - EXPECT_TRUE(graph.has_unit(1)); - EXPECT_TRUE(graph.has_unit(2)); - EXPECT_TRUE(graph.has_unit(3)); - co_return; - }); -} - -TEST_CASE(CompileNoDeps) { - std::vector compiled; - run_test(tracking_dispatch(compiled), [this, &compiled](CompileGraph& graph) -> et::task<> { - graph.register_unit(1, {}); - - auto result = co_await graph.compile(1); - EXPECT_TRUE(result); - // compile() only ensures deps are ready; unit 1 has no deps, - // so dispatch is never called (source files are compiled by Workers). - EXPECT_TRUE(compiled.empty()); - }); -} - TEST_CASE(CompileWithDependency) { + et::event_loop loop; std::vector compiled; - run_test(tracking_dispatch(compiled), [this, &compiled](CompileGraph& graph) -> et::task<> { - // Unit 1 depends on unit 2. - graph.register_unit(1, {2}); - - auto result = co_await graph.compile(1); - EXPECT_TRUE(result); - EXPECT_EQ(compiled.size(), 1u); - EXPECT_EQ(compiled[0], 2u); + // Unit 1 depends on unit 2. + CompileGraph graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2}} + })); + + auto test = [this, &graph, &compiled]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + // Both 2 (dep) and 1 (self) should be compiled, in that order. + EXPECT_EQ(compiled.size(), 2u); + auto pos2 = std::find(compiled.begin(), compiled.end(), 2u); + auto pos1 = std::find(compiled.begin(), compiled.end(), 1u); + EXPECT_TRUE(pos2 < pos1); + EXPECT_FALSE(graph.is_dirty(1)); EXPECT_FALSE(graph.is_dirty(2)); - }); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); } TEST_CASE(CompileChain) { + et::event_loop loop; std::vector compiled; - run_test(tracking_dispatch(compiled), [this, &compiled](CompileGraph& graph) -> et::task<> { - // Chain: 1 -> 2 -> 3. - graph.register_unit(3, {}); - graph.register_unit(2, {3}); - graph.register_unit(1, {2}); - - auto result = co_await graph.compile(1); - EXPECT_TRUE(result); - - // Both 2 and 3 should have been compiled (3 first, then 2). - EXPECT_EQ(compiled.size(), 2u); - // 3 must be compiled before 2. + // Chain: 1 -> 2 -> 3. + CompileGraph graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2}}, + {2, {3}} + })); + + auto test = [this, &graph, &compiled]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); + EXPECT_EQ(compiled.size(), 3u); + // 3 before 2 before 1. auto pos3 = std::find(compiled.begin(), compiled.end(), 3u); auto pos2 = std::find(compiled.begin(), compiled.end(), 2u); + auto pos1 = std::find(compiled.begin(), compiled.end(), 1u); EXPECT_TRUE(pos3 < pos2); - }); + EXPECT_TRUE(pos2 < pos1); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); } TEST_CASE(DiamondDependency) { + et::event_loop loop; std::vector compiled; - run_test(tracking_dispatch(compiled), [this, &compiled](CompileGraph& graph) -> et::task<> { - // Diamond: 1 -> {2, 3}, 2 -> 4, 3 -> 4. - graph.register_unit(4, {}); - graph.register_unit(3, {4}); - graph.register_unit(2, {4}); - graph.register_unit(1, {2, 3}); - - auto result = co_await graph.compile(1); - EXPECT_TRUE(result); - + // Diamond: 1 -> {2, 3}, 2 -> 4, 3 -> 4. + CompileGraph graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2, 3}}, + {2, {4} }, + {3, {4} } + })); + + auto test = [this, &graph, &compiled]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_TRUE(*result); // Unit 4 should be compiled exactly once (dedup). auto count4 = std::count(compiled.begin(), compiled.end(), 4u); EXPECT_EQ(count4, 1); - - // All deps should be clean. EXPECT_FALSE(graph.is_dirty(2)); EXPECT_FALSE(graph.is_dirty(3)); EXPECT_FALSE(graph.is_dirty(4)); - }); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); } TEST_CASE(UpdateInvalidates) { - run_test(instant_dispatch(), [this](CompileGraph& graph) -> et::task<> { - graph.register_unit(1, {2}); - - // Compile everything. - co_await graph.compile(1); + et::event_loop loop; + // 1 -> 2. + CompileGraph graph(instant_dispatch(), + static_resolver({ + {1, {2}} + })); + + auto test = [this, &graph]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); EXPECT_FALSE(graph.is_dirty(2)); + EXPECT_FALSE(graph.is_dirty(1)); - // Update unit 2 — should become dirty again. graph.update(2); EXPECT_TRUE(graph.is_dirty(2)); - }); + // Cascade: 1 depends on 2, so 1 should also be dirty. + EXPECT_TRUE(graph.is_dirty(1)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); } TEST_CASE(UpdateCascade) { - run_test(instant_dispatch(), [this](CompileGraph& graph) -> et::task<> { - // Chain: 1 -> 2 -> 3. - graph.register_unit(3, {}); - graph.register_unit(2, {3}); - graph.register_unit(1, {2}); - - // Compile everything. - co_await graph.compile(1); + et::event_loop loop; + // Chain: 1 -> 2 -> 3. + CompileGraph graph(instant_dispatch(), + static_resolver({ + {1, {2}}, + {2, {3}} + })); + + auto test = [this, &graph]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); EXPECT_FALSE(graph.is_dirty(2)); EXPECT_FALSE(graph.is_dirty(3)); @@ -158,57 +191,310 @@ TEST_CASE(UpdateCascade) { EXPECT_TRUE(graph.is_dirty(3)); EXPECT_TRUE(graph.is_dirty(2)); EXPECT_TRUE(graph.is_dirty(1)); - }); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); } TEST_CASE(CompileAfterUpdate) { + et::event_loop loop; + std::vector compiled; + // 1 -> 2. + CompileGraph graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2}} + })); + + auto test = [this, &graph, &compiled]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); + EXPECT_EQ(compiled.size(), 2u); + + graph.update(2); + co_await graph.compile(1).catch_cancel(); + // 2 and 1 should be recompiled. + EXPECT_EQ(compiled.size(), 4u); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(DispatchFailure) { + et::event_loop loop; + // 1 -> 2. Dispatch always fails. + CompileGraph graph(failing_dispatch(), + static_resolver({ + {1, {2}} + })); + + auto test = [this, &graph]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + // Dep 2 failed, so it stays dirty. + EXPECT_TRUE(graph.is_dirty(2)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(CancelAll) { + CompileGraph graph(instant_dispatch(), no_deps()); + // Just verify it doesn't crash. + graph.cancel_all(); +} + +TEST_CASE(SecondCompileSkips) { + et::event_loop loop; std::vector compiled; - run_test(tracking_dispatch(compiled), [this, &compiled](CompileGraph& graph) -> et::task<> { - graph.register_unit(1, {2}); + CompileGraph graph(tracking_dispatch(compiled), no_deps()); - // First compile. - co_await graph.compile(1); + auto test = [this, &graph, &compiled]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); EXPECT_EQ(compiled.size(), 1u); + // Second compile should skip (already clean). + co_await graph.compile(1).catch_cancel(); + EXPECT_EQ(compiled.size(), 1u); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(CascadeThroughAlreadyDirty) { + et::event_loop loop; + // Chain: 1 -> 2 -> 3. + CompileGraph graph(instant_dispatch(), + static_resolver({ + {1, {2}}, + {2, {3}} + })); - // Update and recompile. + auto test = [this, &graph]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); + + // Update node 2: marks 2 and 1 dirty. graph.update(2); - co_await graph.compile(1); + EXPECT_TRUE(graph.is_dirty(1)); + EXPECT_TRUE(graph.is_dirty(2)); + EXPECT_FALSE(graph.is_dirty(3)); - // Unit 2 should have been compiled a second time. - EXPECT_EQ(compiled.size(), 2u); - EXPECT_EQ(compiled[1], 2u); - }); + // Now update node 3: must cascade through already-dirty 2 to reach 1. + graph.update(3); + EXPECT_TRUE(graph.is_dirty(3)); + EXPECT_TRUE(graph.is_dirty(2)); + EXPECT_TRUE(graph.is_dirty(1)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); } -TEST_CASE(CompileUnregistered) { - run_test(instant_dispatch(), [this](CompileGraph& graph) -> et::task<> { - // Compiling an unregistered unit should succeed (no deps to wait for). - auto result = co_await graph.compile(999); - EXPECT_TRUE(result); - }); +TEST_CASE(CircularDependencyDetection) { + et::event_loop loop; + // Cycle: 1 -> 2 -> 1. + CompileGraph graph(instant_dispatch(), + static_resolver({ + {1, {2}}, + {2, {1}} + })); + + auto test = [this, &graph]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + // Should return false (cycle detected), not deadlock. + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); } -TEST_CASE(DispatchFailure) { - run_test(failing_dispatch(), [this](CompileGraph& graph) -> et::task<> { - graph.register_unit(1, {2}); +TEST_CASE(UpdateResetsResolved) { + et::event_loop loop; + std::vector compiled; + int resolve_count = 0; + // 1 depends on {2} initially; after update, depends on {3}. + bool updated = false; + auto resolver = [&](std::uint32_t path_id) -> llvm::SmallVector { + if(path_id == 1) { + resolve_count++; + return updated ? llvm::SmallVector{3} + : llvm::SmallVector{2}; + } + return {}; + }; + + CompileGraph graph(tracking_dispatch(compiled), std::move(resolver)); + + auto test = [this, &graph, &compiled, &resolve_count, &updated]() -> et::task<> { + // First compile: resolves 1 -> {2}. + co_await graph.compile(1).catch_cancel(); + EXPECT_EQ(resolve_count, 1); + EXPECT_EQ(compiled.size(), 2u); // 2, then 1 + + // Update node 1: resets resolved, changes deps. + updated = true; + graph.update(1); + + // Recompile: should re-resolve 1 -> {3}. + co_await graph.compile(1).catch_cancel(); + EXPECT_EQ(resolve_count, 2); + // New dep 3 should be compiled, then 1 recompiled. + EXPECT_TRUE(std::find(compiled.begin() + 2, compiled.end(), 3u) != compiled.end()); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(UpdateCleansStaleBackEdges) { + et::event_loop loop; + std::vector compiled; + bool updated = false; + auto resolver = [&](std::uint32_t path_id) -> llvm::SmallVector { + if(path_id == 1) { + // Initially depends on 2; after update, no deps. + return updated ? llvm::SmallVector{} + : llvm::SmallVector{2}; + } + return {}; + }; + + CompileGraph graph(tracking_dispatch(compiled), std::move(resolver)); + + auto test = [this, &graph, &compiled, &updated]() -> et::task<> { + // First compile: 1 -> {2}. + co_await graph.compile(1).catch_cancel(); + EXPECT_FALSE(graph.is_dirty(1)); + + // Update 1: resets resolved, removes dep on 2. + updated = true; + graph.update(1); + + // Recompile: 1 has no deps now. + co_await graph.compile(1).catch_cancel(); + EXPECT_FALSE(graph.is_dirty(1)); + + // Now update 2: should NOT cascade to 1 (back-edge was removed). + graph.update(2); + EXPECT_TRUE(graph.is_dirty(2)); + EXPECT_FALSE(graph.is_dirty(1)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} - auto result = co_await graph.compile(1); - // Dependency compilation failed → compile returns false. - EXPECT_FALSE(result); - // Unit 2 should still be dirty (compilation failed). +TEST_CASE(DiamondUpdateCascade) { + et::event_loop loop; + std::vector compiled; + // Diamond: 1 -> {2, 3}, 2 -> 4, 3 -> 4. + CompileGraph graph(tracking_dispatch(compiled), + static_resolver({ + {1, {2, 3}}, + {2, {4} }, + {3, {4} } + })); + + auto test = [this, &graph, &compiled]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); + EXPECT_FALSE(graph.is_dirty(1)); + EXPECT_FALSE(graph.is_dirty(4)); + + // Update leaf 4: should cascade to 2, 3, and 1. + graph.update(4); + EXPECT_TRUE(graph.is_dirty(4)); EXPECT_TRUE(graph.is_dirty(2)); - }); + EXPECT_TRUE(graph.is_dirty(3)); + EXPECT_TRUE(graph.is_dirty(1)); + + compiled.clear(); + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value() && *result); + // Unit 4 should still be compiled exactly once (dedup on recompile). + auto count4 = std::count(compiled.begin(), compiled.end(), 4u); + EXPECT_EQ(count4, 1); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); } -TEST_CASE(CancelAll) { - run_test(instant_dispatch(), [this](CompileGraph& graph) -> et::task<> { - graph.register_unit(1, {}); - graph.register_unit(2, {}); - - // cancel_all should not crash. - graph.cancel_all(); - co_return; - }); +TEST_CASE(UpdateReturnsAllDirtied) { + et::event_loop loop; + // Chain: 1 -> 2 -> 3. + CompileGraph graph(instant_dispatch(), + static_resolver({ + {1, {2}}, + {2, {3}} + })); + + auto test = [this, &graph]() -> et::task<> { + co_await graph.compile(1).catch_cancel(); + + auto dirtied = graph.update(3); + // Should return 3, 2, 1 (all dirtied nodes). + EXPECT_EQ(dirtied.size(), 3u); + EXPECT_TRUE(llvm::find(dirtied, 1u) != dirtied.end()); + EXPECT_TRUE(llvm::find(dirtied, 2u) != dirtied.end()); + EXPECT_TRUE(llvm::find(dirtied, 3u) != dirtied.end()); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(HasUnitAndIsCompiling) { + et::event_loop loop; + CompileGraph graph(instant_dispatch(), no_deps()); + + auto test = [this, &graph]() -> et::task<> { + EXPECT_FALSE(graph.has_unit(1)); + EXPECT_FALSE(graph.is_compiling(1)); + + co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(graph.has_unit(1)); + EXPECT_FALSE(graph.is_compiling(1)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(DispatchFailureLeavesDepDirty) { + et::event_loop loop; + // 1 -> 2. Dispatch always fails. + CompileGraph graph(failing_dispatch(), + static_resolver({ + {1, {2}} + })); + + auto test = [this, &graph]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + // Both dep and self should stay dirty. + EXPECT_TRUE(graph.is_dirty(2)); + EXPECT_TRUE(graph.is_dirty(1)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); } }; // TEST_SUITE(CompileGraph) diff --git a/tests/unit/server/module_worker_tests.cpp b/tests/unit/server/module_worker_tests.cpp new file mode 100644 index 000000000..11facacf3 --- /dev/null +++ b/tests/unit/server/module_worker_tests.cpp @@ -0,0 +1,274 @@ +#include +#include + +#include "test/test.h" +#include "server/protocol.h" +#include "server/worker_test_helpers.h" + +namespace clice::testing { + +namespace { + +namespace et = eventide; + +// ============================================================================ +// End-to-end module compilation through real workers: +// 1. Stateless worker builds PCM for module interface +// 2. Stateful worker compiles a file that imports the module using the PCM +// This tests the same pipeline as MasterServer.run_build_drain(). +// ============================================================================ + +TEST_SUITE(ModuleWorker) { + +TEST_CASE(BuildPCMThenCompileWithImport) { + // Module interface: produces PCM. + TempFile iface("mod_iface.cppm", + "export module Hello;\n" "export const char* hello() { return \"world\"; }\n"); + + // Consumer: imports the module. + TempFile consumer("consumer.cpp", "import Hello;\n" "int main() { return hello()[0]; }\n"); + + // --- Phase 1: Build PCM via stateless worker --- + WorkerHandle sl; + ASSERT_TRUE(sl.spawn("stateless-worker")); + + std::string pcm_path; + bool phase1_done = false; + + sl.run([&]() -> et::task<> { + worker::BuildPCMParams params; + params.file = iface.path; + params.directory = "/tmp"; + params.arguments = {"clang++", + "-resource-dir", + std::string(resource_dir()), + "-std=c++20", + "--precompile", + iface.path}; + params.module_name = "Hello"; + + auto result = co_await sl.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value()); + CO_ASSERT_TRUE(result.value().success); + pcm_path = result.value().pcm_path; + EXPECT_FALSE(pcm_path.empty()); + + phase1_done = true; + sl.peer->close_output(); + }); + + ASSERT_TRUE(phase1_done); + ASSERT_FALSE(pcm_path.empty()); + + // --- Phase 2: Compile consumer with the PCM via stateful worker --- + WorkerHandle sf; + ASSERT_TRUE(sf.spawn("stateful-worker")); + + bool phase2_done = false; + + sf.run([&]() -> et::task<> { + worker::CompileParams params; + params.path = consumer.path; + params.version = 1; + params.text = "import Hello;\n" "int main() { return hello()[0]; }\n"; + params.directory = "/tmp"; + params.arguments = {"clang++", + "-resource-dir", + std::string(resource_dir()), + "-std=c++20", + "-fsyntax-only", + consumer.path}; + // Pass the PCM — same as MasterServer fills CompileParams.pcms. + params.pcms = { + {"Hello", pcm_path} + }; + + 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 PCM temp file. + std::remove(pcm_path.c_str()); +} + +TEST_CASE(BuildPCMChainThenCompile) { + // Module A: no deps. + TempFile mod_a("chain_a.cppm", "export module A;\n" "export int val_a() { return 1; }\n"); + // Module B: imports A. + TempFile mod_b("chain_b.cppm", + "export module B;\n" + "import A;\n" + "export int val_b() { return val_a() + 1; }\n"); + // Consumer: imports B (transitively needs A). + TempFile consumer("chain_consumer.cpp", "import B;\n" "int main() { return val_b(); }\n"); + + WorkerHandle sl; + ASSERT_TRUE(sl.spawn("stateless-worker")); + + std::string pcm_a, pcm_b; + bool pcm_done = false; + + sl.run([&]() -> et::task<> { + // Build PCM for A first. + { + worker::BuildPCMParams params; + params.file = mod_a.path; + params.directory = "/tmp"; + params.arguments = {"clang++", + "-resource-dir", + std::string(resource_dir()), + "-std=c++20", + "--precompile", + mod_a.path}; + params.module_name = "A"; + + auto result = co_await sl.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value() && result.value().success); + pcm_a = result.value().pcm_path; + } + + // Build PCM for B, passing A's PCM (transitive dep). + { + worker::BuildPCMParams params; + params.file = mod_b.path; + params.directory = "/tmp"; + params.arguments = {"clang++", + "-resource-dir", + std::string(resource_dir()), + "-std=c++20", + "--precompile", + mod_b.path}; + params.module_name = "B"; + params.pcms = { + {"A", pcm_a} + }; + + auto result = co_await sl.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value() && result.value().success); + pcm_b = result.value().pcm_path; + } + + pcm_done = true; + sl.peer->close_output(); + }); + + ASSERT_TRUE(pcm_done); + + // Compile consumer with BOTH PCMs via stateful worker. + WorkerHandle sf; + ASSERT_TRUE(sf.spawn("stateful-worker")); + + bool compile_done = false; + + sf.run([&]() -> et::task<> { + worker::CompileParams params; + params.path = consumer.path; + params.version = 1; + params.text = "import B;\n" "int main() { return val_b(); }\n"; + params.directory = "/tmp"; + params.arguments = {"clang++", + "-resource-dir", + std::string(resource_dir()), + "-std=c++20", + "-fsyntax-only", + consumer.path}; + // Clang needs ALL transitive PCMs. + params.pcms = { + {"A", pcm_a}, + {"B", pcm_b} + }; + + 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); + + std::remove(pcm_a.c_str()); + std::remove(pcm_b.c_str()); +} + +TEST_CASE(ModuleImplementationUnitWithWorker) { + // Module interface. + TempFile iface("impl_iface.cppm", "export module Calc;\n" "export int add(int a, int b);\n"); + // Module implementation unit (no export). + TempFile impl("impl_unit.cpp", "module Calc;\n" "int add(int a, int b) { return a + b; }\n"); + + // Build PCM for interface. + WorkerHandle sl; + ASSERT_TRUE(sl.spawn("stateless-worker")); + + std::string pcm_path; + bool pcm_done = false; + + sl.run([&]() -> et::task<> { + worker::BuildPCMParams params; + params.file = iface.path; + params.directory = "/tmp"; + params.arguments = {"clang++", + "-resource-dir", + std::string(resource_dir()), + "-std=c++20", + "--precompile", + iface.path}; + params.module_name = "Calc"; + + auto result = co_await sl.peer->send_request(params); + CO_ASSERT_TRUE(result.has_value() && result.value().success); + pcm_path = result.value().pcm_path; + + pcm_done = true; + sl.peer->close_output(); + }); + + ASSERT_TRUE(pcm_done); + + // Compile implementation unit with the PCM via stateful worker. + WorkerHandle sf; + ASSERT_TRUE(sf.spawn("stateful-worker")); + + bool compile_done = false; + + sf.run([&]() -> et::task<> { + worker::CompileParams params; + params.path = impl.path; + params.version = 1; + params.text = "module Calc;\n" "int add(int a, int b) { return a + b; }\n"; + params.directory = "/tmp"; + params.arguments = {"clang++", + "-resource-dir", + std::string(resource_dir()), + "-std=c++20", + "-fsyntax-only", + impl.path}; + params.pcms = { + {"Calc", pcm_path} + }; + + 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); + + std::remove(pcm_path.c_str()); +} + +}; // TEST_SUITE(ModuleWorker) + +} // namespace +} // namespace clice::testing From b0fae33a0707f7fd08fbcb36f20fde2cc75b3b0c Mon Sep 17 00:00:00 2001 From: ykiko Date: Sun, 29 Mar 2026 03:29:21 +0800 Subject: [PATCH 3/5] fix: use forward-slash paths in CDB for Windows compatibility On Windows, str(Path(...)) produces backslash paths but the server's URI-to-path conversion produces forward-slash paths, causing CDB lookup failures. Use .as_posix() for all CDB path entries. Co-Authored-By: Claude Opus 4.6 --- tests/conftest.py | 6 +++--- tests/integration/test_modules.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c1c93d4a0..41236ffd8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -119,9 +119,9 @@ def test_data_dir(): if main_cpp.exists() and not cdb_path.exists(): cdb = [ { - "directory": str(hw_dir), - "file": str(main_cpp), - "arguments": ["clang++", "-std=c++17", "-fsyntax-only", str(main_cpp)], + "directory": hw_dir.as_posix(), + "file": main_cpp.as_posix(), + "arguments": ["clang++", "-std=c++17", "-fsyntax-only", main_cpp.as_posix()], } ] cdb_path.write_text(json.dumps(cdb, indent=2)) diff --git a/tests/integration/test_modules.py b/tests/integration/test_modules.py index e27128cf7..d285c29ae 100644 --- a/tests/integration/test_modules.py +++ b/tests/integration/test_modules.py @@ -37,11 +37,11 @@ def _write_cdb(workspace: Path, files: list[str], extra_args: list[str] | None = args = ["clang++", "-std=c++20", "-fsyntax-only"] if extra_args: args.extend(extra_args) - args.append(str(workspace / f)) + args.append((workspace / f).as_posix()) cdb.append( { - "directory": str(workspace), - "file": str(workspace / f), + "directory": workspace.as_posix(), + "file": (workspace / f).as_posix(), "arguments": args, } ) @@ -57,11 +57,11 @@ def _write_cdb_entries(workspace: Path, entries: list[tuple[str, list[str]]]): for filename, extra in entries: args = ["clang++", "-std=c++20", "-fsyntax-only"] args.extend(extra) - args.append(str(workspace / filename)) + args.append((workspace / filename).as_posix()) cdb.append( { - "directory": str(workspace), - "file": str(workspace / filename), + "directory": workspace.as_posix(), + "file": (workspace / filename).as_posix(), "arguments": args, } ) From ee35ccbd2aaec2716fae9272651fb8cb7fd2b54c Mon Sep 17 00:00:00 2001 From: ykiko Date: Sun, 29 Mar 2026 03:44:15 +0800 Subject: [PATCH 4/5] style: format conftest.py Co-Authored-By: Claude Opus 4.6 --- tests/conftest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 41236ffd8..3a2a8b284 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -121,7 +121,12 @@ def test_data_dir(): { "directory": hw_dir.as_posix(), "file": main_cpp.as_posix(), - "arguments": ["clang++", "-std=c++17", "-fsyntax-only", main_cpp.as_posix()], + "arguments": [ + "clang++", + "-std=c++17", + "-fsyntax-only", + main_cpp.as_posix(), + ], } ] cdb_path.write_text(json.dumps(cdb, indent=2)) From 5d2d27256929523f93ec41c6c24d4a486e6e0bad Mon Sep 17 00:00:00 2001 From: ykiko Date: Sun, 29 Mar 2026 04:16:59 +0800 Subject: [PATCH 5/5] fix: address CodeRabbit review feedback - Use pre-captured cancellation token for dispatch (compile_graph.cpp) to preserve cancellation correctness when update() replaces the source during dependency compilation. - Check compile_graph->compile() results and skip stateful compile when module dependency builds fail or are cancelled. - Schedule rebuilds for open importers when a module file is saved, so cascade invalidation actually refreshes dependent documents. Co-Authored-By: Claude Opus 4.6 --- src/server/compile_graph.cpp | 7 ++++--- src/server/master_server.cpp | 31 +++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/server/compile_graph.cpp b/src/server/compile_graph.cpp index bfc8d5ea6..6dc2d6ace 100644 --- a/src/server/compile_graph.cpp +++ b/src/server/compile_graph.cpp @@ -94,10 +94,11 @@ et::task CompileGraph::compile_impl(std::uint32_t path_id, } } - // Dispatch the actual compilation, cancellable via this unit's token. + // Dispatch the actual compilation, cancellable via the pre-captured token. + // Using the token captured before co_await ensures cancellation propagates + // correctly even if update() replaces the source during dependency compilation. { - auto result = - co_await et::with_token(dispatch(path_id), units.find(path_id)->second.source->token()); + auto result = co_await et::with_token(dispatch(path_id), token); auto& u = units.find(path_id)->second; if(!result.has_value()) { diff --git a/src/server/master_server.cpp b/src/server/master_server.cpp index cb98866a7..12e4490fd 100644 --- a/src/server/master_server.cpp +++ b/src/server/master_server.cpp @@ -110,12 +110,16 @@ et::task<> MasterServer::run_build_drain(std::uint32_t path_id, std::string uri) auto file_path = path_pool.resolve(path_id); auto cdb_results = cdb.lookup(file_path, {.query_toolchain = true, .suppress_logging = true}); + bool deps_ok = true; if(!cdb_results.empty()) { auto scan_result = scan_precise(cdb_results[0].arguments, cdb_results[0].directory); for(auto& mod_name: scan_result.modules) { auto mod_ids = dependency_graph.lookup_module(mod_name); if(!mod_ids.empty()) { - co_await compile_graph->compile(mod_ids[0]).catch_cancel(); + auto r = co_await compile_graph->compile(mod_ids[0]).catch_cancel(); + if(!r.has_value() || !*r) { + deps_ok = false; + } } } // Module implementation units (module M; without export) need @@ -123,10 +127,21 @@ et::task<> MasterServer::run_build_drain(std::uint32_t path_id, std::string uri) if(!scan_result.module_name.empty() && !scan_result.is_interface_unit) { auto mod_ids = dependency_graph.lookup_module(scan_result.module_name); if(!mod_ids.empty()) { - co_await compile_graph->compile(mod_ids[0]).catch_cancel(); + auto r = co_await compile_graph->compile(mod_ids[0]).catch_cancel(); + if(!r.has_value() || !*r) { + deps_ok = false; + } } } } + if(!deps_ok) { + LOG_WARN("Module dependency build failed for {}, skipping compile", uri); + doc_it = documents.find(path_id); + if(doc_it != documents.end()) { + doc_it->second.build_running = false; + } + co_return; + } } // Send compile request to stateful worker @@ -649,6 +664,18 @@ void MasterServer::register_handlers() { for(auto dirty_id: dirtied) { pcm_paths.erase(dirty_id); } + // Schedule rebuilds for dirtied units that are currently open. + for(auto dirty_id: dirtied) { + if(dirty_id == path_id) + continue; // The saved file itself is rebuilt by its own didChange. + if(documents.count(dirty_id)) { + auto dirty_path = path_pool.resolve(dirty_id); + auto uri = lsp::URI::from_file_path(dirty_path); + if(uri.has_value()) { + schedule_build(dirty_id, uri->str()); + } + } + } } LOG_DEBUG("didSave: {}", params.text_document.uri);