diff --git a/src/feature/code_completion.cpp b/src/feature/code_completion.cpp index c15e29a5..c6bce6aa 100644 --- a/src/feature/code_completion.cpp +++ b/src/feature/code_completion.cpp @@ -148,8 +148,8 @@ public: PositionMapper converter(content, encoding); auto replace_range = protocol::Range{ - .start = converter.to_position(prefix.range.begin), - .end = converter.to_position(prefix.range.end), + .start = *converter.to_position(prefix.range.begin), + .end = *converter.to_position(prefix.range.end), }; std::vector collected; diff --git a/src/feature/diagnostics.cpp b/src/feature/diagnostics.cpp index 064125e3..b496aaf3 100644 --- a/src/feature/diagnostics.cpp +++ b/src/feature/diagnostics.cpp @@ -153,8 +153,8 @@ auto diagnostics(CompilationUnitRef unit, PositionEncoding encoding) auto offset = unit.file_offset(include_location); auto end_offset = offset + unit.token_spelling(include_location).size(); diagnostic.range = protocol::Range{ - .start = main_converter.to_position(offset), - .end = main_converter.to_position(end_offset), + .start = *main_converter.to_position(offset), + .end = *main_converter.to_position(end_offset), }; current = std::move(diagnostic); diff --git a/src/feature/feature.h b/src/feature/feature.h index 7c0e5b48..d6226d8f 100644 --- a/src/feature/feature.h +++ b/src/feature/feature.h @@ -26,8 +26,8 @@ using eventide::ipc::lsp::parse_position_encoding; inline auto to_range(const PositionMapper& converter, LocalSourceRange range) -> protocol::Range { return protocol::Range{ - .start = converter.to_position(range.begin), - .end = converter.to_position(range.end), + .start = *converter.to_position(range.begin), + .end = *converter.to_position(range.end), }; } diff --git a/src/feature/folding_ranges.cpp b/src/feature/folding_ranges.cpp index eb1c05dc..df4f98a0 100644 --- a/src/feature/folding_ranges.cpp +++ b/src/feature/folding_ranges.cpp @@ -357,8 +357,8 @@ auto folding_ranges(CompilationUnitRef unit, PositionEncoding encoding) result.reserve(collected.size()); for(const auto& item: collected) { - auto start = converter.to_position(item.range.begin); - auto end = converter.to_position(item.range.end); + auto start = *converter.to_position(item.range.begin); + auto end = *converter.to_position(item.range.end); protocol::FoldingRange range{ .start_line = start.line, diff --git a/src/feature/formatting.cpp b/src/feature/formatting.cpp index 7969a987..c80370bd 100644 --- a/src/feature/formatting.cpp +++ b/src/feature/formatting.cpp @@ -57,8 +57,8 @@ auto document_format(llvm::StringRef file, for(const auto& replacement: *replacements) { protocol::TextEdit edit; - edit.range.start = converter.to_position(replacement.getOffset()); - edit.range.end = converter.to_position(replacement.getOffset() + replacement.getLength()); + edit.range.start = *converter.to_position(replacement.getOffset()); + edit.range.end = *converter.to_position(replacement.getOffset() + replacement.getLength()); edit.new_text = replacement.getReplacementText().str(); edits.push_back(std::move(edit)); } diff --git a/src/feature/hover.cpp b/src/feature/hover.cpp index 6e2f9f6f..8c52d6a9 100644 --- a/src/feature/hover.cpp +++ b/src/feature/hover.cpp @@ -50,8 +50,8 @@ auto hover_range(CompilationUnitRef unit, } return protocol::Range{ - .start = converter.to_position(range.begin), - .end = converter.to_position(range.end), + .start = *converter.to_position(range.begin), + .end = *converter.to_position(range.end), }; } diff --git a/src/feature/inlay_hints.cpp b/src/feature/inlay_hints.cpp index 6264a6a0..a0eebd50 100644 --- a/src/feature/inlay_hints.cpp +++ b/src/feature/inlay_hints.cpp @@ -944,7 +944,7 @@ auto inlay_hints(CompilationUnitRef unit, for(const auto& hint: raw_hints) { protocol::InlayHint out{ - .position = converter.to_position(hint.offset), + .position = *converter.to_position(hint.offset), .label = hint.label, }; diff --git a/src/feature/semantic_tokens.cpp b/src/feature/semantic_tokens.cpp index c71efcdb..1b522b7d 100644 --- a/src/feature/semantic_tokens.cpp +++ b/src/feature/semantic_tokens.cpp @@ -241,8 +241,8 @@ public: auto begin = token.range.begin; auto end = token.range.end; - auto begin_position = converter.to_position(begin); - auto end_position = converter.to_position(end); + auto begin_position = *converter.to_position(begin); + auto end_position = *converter.to_position(end); auto begin_line = static_cast(begin_position.line); auto begin_char = static_cast(begin_position.character); auto end_line = static_cast(end_position.line); diff --git a/src/server/master_server.cpp b/src/server/master_server.cpp index 7b4beaf0..e497908e 100644 --- a/src/server/master_server.cpp +++ b/src/server/master_server.cpp @@ -1,5 +1,6 @@ #include "server/master_server.h" +#include #include #include #include @@ -15,6 +16,7 @@ #include "support/filesystem.h" #include "support/logging.h" #include "syntax/dependency_graph.h" +#include "syntax/scan.h" namespace clice { @@ -27,6 +29,12 @@ using RequestContext = et::ipc::JsonPeer::RequestContext; MasterServer::MasterServer(et::event_loop& loop, et::ipc::JsonPeer& peer, std::string self_path) : loop(loop), peer(peer), pool(loop), self_path(std::move(self_path)) {} +MasterServer::~MasterServer() { + if(compile_graph) { + compile_graph->cancel_all(); + } +} + std::string MasterServer::uri_to_path(const std::string& uri) { auto parsed = lsp::URI::parse(uri); if(parsed.has_value()) { @@ -77,7 +85,7 @@ void MasterServer::schedule_build(std::uint32_t path_id, const std::string& uri) // Create or reset debounce timer auto& timer_ptr = debounce_timers[path_id]; if(!timer_ptr) { - timer_ptr = std::make_unique(et::timer::create(loop)); + timer_ptr = std::make_shared(et::timer::create(loop)); } timer_ptr->start(std::chrono::milliseconds(config.debounce_ms)); @@ -88,10 +96,12 @@ void MasterServer::schedule_build(std::uint32_t path_id, const std::string& uri) } et::task<> MasterServer::run_build_drain(std::uint32_t path_id, std::string uri) { - // Wait for debounce timer - auto timer_it = debounce_timers.find(path_id); - if(timer_it != debounce_timers.end() && timer_it->second) { - co_await timer_it->second->wait(); + // Wait for debounce timer. Hold a shared_ptr copy so the timer + // stays alive even if didClose erases the map entry mid-wait. + if(auto timer_it = debounce_timers.find(path_id); + timer_it != debounce_timers.end() && timer_it->second) { + auto timer = timer_it->second; + co_await timer->wait(); } while(true) { @@ -103,12 +113,73 @@ 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) { + 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()) { + auto r = co_await compile_graph->compile(mod_ids[0]); + if(!r) { + deps_ok = false; + break; + } + } + } + // Module implementation units need their interface PCM. + if(deps_ok && !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()) { + auto r = co_await compile_graph->compile(mod_ids[0]); + if(!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; + doc_it->second.drain_scheduled = false; + } + co_return; + } + } + + // Re-lookup document after co_awaits in compile_graph section. + doc_it = documents.find(path_id); + if(doc_it == documents.end()) + co_return; + // Send compile request to stateful worker worker::CompileParams params; params.path = std::string(path_pool.resolve(path_id)); params.version = doc_it->second.version; params.text = doc_it->second.text; - fill_compile_args(path_pool.resolve(path_id), params.directory, params.arguments); + if(!fill_compile_args(path_pool.resolve(path_id), params.directory, params.arguments)) { + doc_it->second.build_running = false; + doc_it->second.drain_scheduled = false; + co_return; + } + + // 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, @@ -217,18 +288,95 @@ 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; + if(!fill_compile_args(file_path, pcm_params.directory, pcm_params.arguments)) { + co_return false; + } + 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, +bool 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 false; + } auto& ctx = results.front(); directory = ctx.directory.str(); arguments.clear(); for(auto* arg: ctx.arguments) { arguments.emplace_back(arg); } + return true; } et::task MasterServer::ensure_compiled(std::uint32_t path_id, const std::string& uri) { @@ -280,7 +428,10 @@ MasterServer::RawResult MasterServer::forward_stateful(const std::string& uri, auto doc_it = documents.find(path_id); if(doc_it != documents.end()) { lsp::PositionMapper mapper(doc_it->second.text, lsp::PositionEncoding::UTF16); - wp.offset = mapper.to_offset(position); + auto offset = mapper.to_offset(position); + if(!offset) + co_return serde_raw{"null"}; + wp.offset = *offset; } auto result = co_await pool.send_stateful(path_id, wp); @@ -301,14 +452,18 @@ MasterServer::RawResult MasterServer::forward_stateless(const std::string& uri, auto& doc = doc_it->second; - lsp::PositionMapper mapper(doc.text, lsp::PositionEncoding::UTF16); - WorkerParams wp; wp.path = path; wp.version = doc.version; wp.text = doc.text; - fill_compile_args(path, wp.directory, wp.arguments); - wp.offset = mapper.to_offset(position); + if(!fill_compile_args(path, wp.directory, wp.arguments)) + co_return serde_raw{}; + + lsp::PositionMapper mapper(doc.text, lsp::PositionEncoding::UTF16); + auto offset = mapper.to_offset(position); + if(!offset) + co_return serde_raw{"null"}; + wp.offset = *offset; auto result = co_await pool.send_stateless(wp); if(!result.has_value()) @@ -485,8 +640,8 @@ void MasterServer::register_handlers() { lsp::PositionMapper mapper(doc.text, lsp::PositionEncoding::UTF16); auto start = mapper.to_offset(range.start); auto end = mapper.to_offset(range.end); - if(start <= doc.text.size() && end <= doc.text.size() && start <= end) { - doc.text.replace(start, end - start, c.text); + if(start && end && *start <= *end) { + doc.text.replace(*start, *end - *start, c.text); } } }, @@ -513,6 +668,11 @@ void MasterServer::register_handlers() { auto path = uri_to_path(params.text_document.uri); auto path_id = path_pool.intern(path); + // Cancel in-flight module compilations for this file. + if(compile_graph && compile_graph->has_unit(path_id)) { + compile_graph->update(path_id); + } + documents.erase(path_id); debounce_timers.erase(path_id); @@ -527,7 +687,30 @@ 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); + } + // 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.contains(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); }); diff --git a/src/server/master_server.h b/src/server/master_server.h index f87d582d..24fa1ff9 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" @@ -44,6 +45,7 @@ enum class ServerLifecycle : std::uint8_t { class MasterServer { public: MasterServer(et::event_loop& loop, et::ipc::JsonPeer& peer, std::string self_path); + ~MasterServer(); void register_handlers(); @@ -61,11 +63,20 @@ private: 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; - // Per-document debounce timers - llvm::DenseMap> debounce_timers; + // Per-document debounce timers (shared_ptr so drain coroutines survive didClose) + llvm::DenseMap> debounce_timers; // Helper: convert URI to file path std::string uri_to_path(const std::string& uri); @@ -89,7 +100,7 @@ private: et::task<> load_workspace(); // Helper: fill compile arguments from CDB into worker params - void fill_compile_args(llvm::StringRef path, + bool fill_compile_args(llvm::StringRef path, std::string& directory, std::vector& arguments); diff --git a/src/server/protocol.h b/src/server/protocol.h index 03e6a2b8..1aaff539 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 1cfe47b2..2fe456f9 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/conftest.py b/tests/conftest.py index c1c93d4a..3a2a8b28 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -119,9 +119,14 @@ 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/data/modules/chained_modules/mod_a.cppm b/tests/data/modules/chained_modules/mod_a.cppm new file mode 100644 index 00000000..9823e0be --- /dev/null +++ b/tests/data/modules/chained_modules/mod_a.cppm @@ -0,0 +1,2 @@ +export module A; +export int foo() { return 42; } diff --git a/tests/data/modules/chained_modules/mod_b.cppm b/tests/data/modules/chained_modules/mod_b.cppm new file mode 100644 index 00000000..50adc1f4 --- /dev/null +++ b/tests/data/modules/chained_modules/mod_b.cppm @@ -0,0 +1,3 @@ +export module B; +import A; +export int bar() { return foo() + 1; } diff --git a/tests/data/modules/circular_module_dependency/cycle_a.cppm b/tests/data/modules/circular_module_dependency/cycle_a.cppm new file mode 100644 index 00000000..91f74918 --- /dev/null +++ b/tests/data/modules/circular_module_dependency/cycle_a.cppm @@ -0,0 +1,3 @@ +export module CycA; +import CycB; +export int a() { return 1; } diff --git a/tests/data/modules/circular_module_dependency/cycle_b.cppm b/tests/data/modules/circular_module_dependency/cycle_b.cppm new file mode 100644 index 00000000..088c3cc2 --- /dev/null +++ b/tests/data/modules/circular_module_dependency/cycle_b.cppm @@ -0,0 +1,3 @@ +export module CycB; +import CycA; +export int b() { return 2; } diff --git a/tests/data/modules/circular_module_dependency/ok.cppm b/tests/data/modules/circular_module_dependency/ok.cppm new file mode 100644 index 00000000..00727c1e --- /dev/null +++ b/tests/data/modules/circular_module_dependency/ok.cppm @@ -0,0 +1,2 @@ +export module Ok; +export int ok() { return 42; } diff --git a/tests/data/modules/class_export_and_inheritance/circle.cppm b/tests/data/modules/class_export_and_inheritance/circle.cppm new file mode 100644 index 00000000..d6e79259 --- /dev/null +++ b/tests/data/modules/class_export_and_inheritance/circle.cppm @@ -0,0 +1,8 @@ +export module Circle; +import Shape; +export class Circle : public Shape { + int r; +public: + Circle(int r) : r(r) {} + int area() const override { return 3 * r * r; } +}; diff --git a/tests/data/modules/class_export_and_inheritance/shape.cppm b/tests/data/modules/class_export_and_inheritance/shape.cppm new file mode 100644 index 00000000..dbda3b12 --- /dev/null +++ b/tests/data/modules/class_export_and_inheritance/shape.cppm @@ -0,0 +1,6 @@ +export module Shape; +export class Shape { +public: + virtual ~Shape() = default; + virtual int area() const = 0; +}; diff --git a/tests/data/modules/consumer_imports_module/main.cpp b/tests/data/modules/consumer_imports_module/main.cpp new file mode 100644 index 00000000..91656cd7 --- /dev/null +++ b/tests/data/modules/consumer_imports_module/main.cpp @@ -0,0 +1,5 @@ +import Math; + +int main() { + return add(1, 2); +} diff --git a/tests/data/modules/consumer_imports_module/math.cppm b/tests/data/modules/consumer_imports_module/math.cppm new file mode 100644 index 00000000..665bb79d --- /dev/null +++ b/tests/data/modules/consumer_imports_module/math.cppm @@ -0,0 +1,2 @@ +export module Math; +export int add(int a, int b) { return a + b; } diff --git a/tests/data/modules/deep_chain/m1.cppm b/tests/data/modules/deep_chain/m1.cppm new file mode 100644 index 00000000..56fe2cf4 --- /dev/null +++ b/tests/data/modules/deep_chain/m1.cppm @@ -0,0 +1,2 @@ +export module M1; +export int f1() { return 1; } diff --git a/tests/data/modules/deep_chain/m2.cppm b/tests/data/modules/deep_chain/m2.cppm new file mode 100644 index 00000000..5393ba1c --- /dev/null +++ b/tests/data/modules/deep_chain/m2.cppm @@ -0,0 +1,3 @@ +export module M2; +import M1; +export int f2() { return f1() + 1; } diff --git a/tests/data/modules/deep_chain/m3.cppm b/tests/data/modules/deep_chain/m3.cppm new file mode 100644 index 00000000..6c253295 --- /dev/null +++ b/tests/data/modules/deep_chain/m3.cppm @@ -0,0 +1,3 @@ +export module M3; +import M2; +export int f3() { return f2() + 1; } diff --git a/tests/data/modules/deep_chain/m4.cppm b/tests/data/modules/deep_chain/m4.cppm new file mode 100644 index 00000000..081b9919 --- /dev/null +++ b/tests/data/modules/deep_chain/m4.cppm @@ -0,0 +1,3 @@ +export module M4; +import M3; +export int f4() { return f3() + 1; } diff --git a/tests/data/modules/deep_chain/m5.cppm b/tests/data/modules/deep_chain/m5.cppm new file mode 100644 index 00000000..f5cae81a --- /dev/null +++ b/tests/data/modules/deep_chain/m5.cppm @@ -0,0 +1,3 @@ +export module M5; +import M4; +export int f5() { return f4() + 1; } diff --git a/tests/data/modules/diamond_modules/base.cppm b/tests/data/modules/diamond_modules/base.cppm new file mode 100644 index 00000000..afb06de7 --- /dev/null +++ b/tests/data/modules/diamond_modules/base.cppm @@ -0,0 +1,2 @@ +export module Base; +export int base_val() { return 10; } diff --git a/tests/data/modules/diamond_modules/left.cppm b/tests/data/modules/diamond_modules/left.cppm new file mode 100644 index 00000000..48b7329d --- /dev/null +++ b/tests/data/modules/diamond_modules/left.cppm @@ -0,0 +1,3 @@ +export module Left; +import Base; +export int left_val() { return base_val() + 1; } diff --git a/tests/data/modules/diamond_modules/right.cppm b/tests/data/modules/diamond_modules/right.cppm new file mode 100644 index 00000000..832452e9 --- /dev/null +++ b/tests/data/modules/diamond_modules/right.cppm @@ -0,0 +1,3 @@ +export module Right; +import Base; +export int right_val() { return base_val() + 2; } diff --git a/tests/data/modules/diamond_modules/top.cppm b/tests/data/modules/diamond_modules/top.cppm new file mode 100644 index 00000000..0290c0e6 --- /dev/null +++ b/tests/data/modules/diamond_modules/top.cppm @@ -0,0 +1,4 @@ +export module Top; +import Left; +import Right; +export int top_val() { return left_val() + right_val(); } diff --git a/tests/data/modules/dotted_module_name/app.cppm b/tests/data/modules/dotted_module_name/app.cppm new file mode 100644 index 00000000..3f6e0cdb --- /dev/null +++ b/tests/data/modules/dotted_module_name/app.cppm @@ -0,0 +1,3 @@ +export module my.app; +import my.io; +export void run() { print(); } diff --git a/tests/data/modules/dotted_module_name/io.cppm b/tests/data/modules/dotted_module_name/io.cppm new file mode 100644 index 00000000..384ef799 --- /dev/null +++ b/tests/data/modules/dotted_module_name/io.cppm @@ -0,0 +1,2 @@ +export module my.io; +export void print() {} diff --git a/tests/data/modules/export_block/block.cppm b/tests/data/modules/export_block/block.cppm new file mode 100644 index 00000000..4c050f78 --- /dev/null +++ b/tests/data/modules/export_block/block.cppm @@ -0,0 +1,8 @@ +export module Block; +export { + int alpha() { return 1; } + int beta() { return 2; } + namespace ns { + int gamma() { return 3; } + } +} diff --git a/tests/data/modules/export_block/consumer.cppm b/tests/data/modules/export_block/consumer.cppm new file mode 100644 index 00000000..9cfc6de3 --- /dev/null +++ b/tests/data/modules/export_block/consumer.cppm @@ -0,0 +1,3 @@ +export module Consumer; +import Block; +export int total() { return alpha() + beta() + ns::gamma(); } diff --git a/tests/data/modules/export_namespace/calc.cppm b/tests/data/modules/export_namespace/calc.cppm new file mode 100644 index 00000000..9c38fdfa --- /dev/null +++ b/tests/data/modules/export_namespace/calc.cppm @@ -0,0 +1,3 @@ +export module Calc; +import NS; +export int compute() { return math::add(3, math::mul(4, 5)); } diff --git a/tests/data/modules/export_namespace/ns.cppm b/tests/data/modules/export_namespace/ns.cppm new file mode 100644 index 00000000..c7e8f8d9 --- /dev/null +++ b/tests/data/modules/export_namespace/ns.cppm @@ -0,0 +1,5 @@ +export module NS; +export namespace math { + int add(int a, int b) { return a + b; } + int mul(int a, int b) { return a * b; } +} diff --git a/tests/data/modules/global_module_fragment/gmf.cppm b/tests/data/modules/global_module_fragment/gmf.cppm new file mode 100644 index 00000000..31308b95 --- /dev/null +++ b/tests/data/modules/global_module_fragment/gmf.cppm @@ -0,0 +1,4 @@ +module; +#include "legacy.h" +export module GMF; +export int wrapped() { return legacy_fn(); } diff --git a/tests/data/modules/global_module_fragment/legacy.h b/tests/data/modules/global_module_fragment/legacy.h new file mode 100644 index 00000000..97e186ac --- /dev/null +++ b/tests/data/modules/global_module_fragment/legacy.h @@ -0,0 +1,3 @@ +inline int legacy_fn() { + return 99; +} diff --git a/tests/data/modules/gmf_with_import/base.cppm b/tests/data/modules/gmf_with_import/base.cppm new file mode 100644 index 00000000..23950098 --- /dev/null +++ b/tests/data/modules/gmf_with_import/base.cppm @@ -0,0 +1,2 @@ +export module Base; +export int base() { return 100; } diff --git a/tests/data/modules/gmf_with_import/combined.cppm b/tests/data/modules/gmf_with_import/combined.cppm new file mode 100644 index 00000000..4fc4b50d --- /dev/null +++ b/tests/data/modules/gmf_with_import/combined.cppm @@ -0,0 +1,5 @@ +module; +#include "util.h" +export module Combined; +import Base; +export int combined() { return base() + util_helper(); } diff --git a/tests/data/modules/gmf_with_import/util.h b/tests/data/modules/gmf_with_import/util.h new file mode 100644 index 00000000..9e8801c8 --- /dev/null +++ b/tests/data/modules/gmf_with_import/util.h @@ -0,0 +1,3 @@ +inline int util_helper() { + return 7; +} diff --git a/tests/data/modules/hover_on_imported_symbol/defs.cppm b/tests/data/modules/hover_on_imported_symbol/defs.cppm new file mode 100644 index 00000000..23c27ecc --- /dev/null +++ b/tests/data/modules/hover_on_imported_symbol/defs.cppm @@ -0,0 +1,2 @@ +export module Defs; +export int magic_number() { return 42; } diff --git a/tests/data/modules/hover_on_imported_symbol/use.cpp b/tests/data/modules/hover_on_imported_symbol/use.cpp new file mode 100644 index 00000000..4b0fa8c4 --- /dev/null +++ b/tests/data/modules/hover_on_imported_symbol/use.cpp @@ -0,0 +1,5 @@ +import Defs; + +int main() { + return magic_number(); +} diff --git a/tests/data/modules/independent_modules/x.cppm b/tests/data/modules/independent_modules/x.cppm new file mode 100644 index 00000000..5a8542f5 --- /dev/null +++ b/tests/data/modules/independent_modules/x.cppm @@ -0,0 +1,2 @@ +export module X; +export int x() { return 1; } diff --git a/tests/data/modules/independent_modules/y.cppm b/tests/data/modules/independent_modules/y.cppm new file mode 100644 index 00000000..37553cdc --- /dev/null +++ b/tests/data/modules/independent_modules/y.cppm @@ -0,0 +1,2 @@ +export module Y; +export int y() { return 2; } diff --git a/tests/data/modules/module_compile_error/bad.cppm b/tests/data/modules/module_compile_error/bad.cppm new file mode 100644 index 00000000..1cf65f37 --- /dev/null +++ b/tests/data/modules/module_compile_error/bad.cppm @@ -0,0 +1,3 @@ +export module Bad; +import Good; +export int bad() { return UNDEFINED_SYMBOL; } diff --git a/tests/data/modules/module_compile_error/good.cppm b/tests/data/modules/module_compile_error/good.cppm new file mode 100644 index 00000000..43e3b993 --- /dev/null +++ b/tests/data/modules/module_compile_error/good.cppm @@ -0,0 +1,2 @@ +export module Good; +export int good() { return 1; } diff --git a/tests/data/modules/module_implementation_unit/greeter.cppm b/tests/data/modules/module_implementation_unit/greeter.cppm new file mode 100644 index 00000000..01a15d77 --- /dev/null +++ b/tests/data/modules/module_implementation_unit/greeter.cppm @@ -0,0 +1,2 @@ +export module Greeter; +export const char* greet(); diff --git a/tests/data/modules/module_implementation_unit/greeter_impl.cpp b/tests/data/modules/module_implementation_unit/greeter_impl.cpp new file mode 100644 index 00000000..79ede781 --- /dev/null +++ b/tests/data/modules/module_implementation_unit/greeter_impl.cpp @@ -0,0 +1,5 @@ +module Greeter; + +const char* greet() { + return "hello"; +} diff --git a/tests/data/modules/module_partitions/lib.cppm b/tests/data/modules/module_partitions/lib.cppm new file mode 100644 index 00000000..0472f8ae --- /dev/null +++ b/tests/data/modules/module_partitions/lib.cppm @@ -0,0 +1,4 @@ +export module Lib; +export import :A; +export import :B; +export int lib_fn() { return a_fn() + b_fn(); } diff --git a/tests/data/modules/module_partitions/part_a.cppm b/tests/data/modules/module_partitions/part_a.cppm new file mode 100644 index 00000000..1152f497 --- /dev/null +++ b/tests/data/modules/module_partitions/part_a.cppm @@ -0,0 +1,2 @@ +export module Lib:A; +export int a_fn() { return 1; } diff --git a/tests/data/modules/module_partitions/part_b.cppm b/tests/data/modules/module_partitions/part_b.cppm new file mode 100644 index 00000000..731a9e50 --- /dev/null +++ b/tests/data/modules/module_partitions/part_b.cppm @@ -0,0 +1,2 @@ +export module Lib:B; +export int b_fn() { return 2; } diff --git a/tests/data/modules/no_modules_plain_cpp/plain.cpp b/tests/data/modules/no_modules_plain_cpp/plain.cpp new file mode 100644 index 00000000..ef26f9eb --- /dev/null +++ b/tests/data/modules/no_modules_plain_cpp/plain.cpp @@ -0,0 +1,7 @@ +int add(int a, int b) { + return a + b; +} + +int main() { + return add(1, 2); +} diff --git a/tests/data/modules/partition_chain/core.cppm b/tests/data/modules/partition_chain/core.cppm new file mode 100644 index 00000000..e2ad946e --- /dev/null +++ b/tests/data/modules/partition_chain/core.cppm @@ -0,0 +1,3 @@ +export module Sys:Core; +import :Types; +export Config make_config() { return {42}; } diff --git a/tests/data/modules/partition_chain/sys.cppm b/tests/data/modules/partition_chain/sys.cppm new file mode 100644 index 00000000..75c3e72d --- /dev/null +++ b/tests/data/modules/partition_chain/sys.cppm @@ -0,0 +1,3 @@ +export module Sys; +export import :Types; +export import :Core; diff --git a/tests/data/modules/partition_chain/types.cppm b/tests/data/modules/partition_chain/types.cppm new file mode 100644 index 00000000..3c277808 --- /dev/null +++ b/tests/data/modules/partition_chain/types.cppm @@ -0,0 +1,2 @@ +export module Sys:Types; +export struct Config { int value = 0; }; diff --git a/tests/data/modules/partition_interface/part.cppm b/tests/data/modules/partition_interface/part.cppm new file mode 100644 index 00000000..be60dfd1 --- /dev/null +++ b/tests/data/modules/partition_interface/part.cppm @@ -0,0 +1,2 @@ +export module M:Part; +export int part_fn() { return 5; } diff --git a/tests/data/modules/partition_interface/primary.cppm b/tests/data/modules/partition_interface/primary.cppm new file mode 100644 index 00000000..b022380c --- /dev/null +++ b/tests/data/modules/partition_interface/primary.cppm @@ -0,0 +1,3 @@ +export module M; +export import :Part; +export int primary_fn() { return part_fn() + 1; } diff --git a/tests/data/modules/partition_with_external_import/app.cppm b/tests/data/modules/partition_with_external_import/app.cppm new file mode 100644 index 00000000..7807c1f6 --- /dev/null +++ b/tests/data/modules/partition_with_external_import/app.cppm @@ -0,0 +1,2 @@ +export module App; +export import :Core; diff --git a/tests/data/modules/partition_with_external_import/ext.cppm b/tests/data/modules/partition_with_external_import/ext.cppm new file mode 100644 index 00000000..9b5c8d02 --- /dev/null +++ b/tests/data/modules/partition_with_external_import/ext.cppm @@ -0,0 +1,2 @@ +export module Ext; +export int ext_val() { return 99; } diff --git a/tests/data/modules/partition_with_external_import/part.cppm b/tests/data/modules/partition_with_external_import/part.cppm new file mode 100644 index 00000000..1920565d --- /dev/null +++ b/tests/data/modules/partition_with_external_import/part.cppm @@ -0,0 +1,3 @@ +export module App:Core; +import Ext; +export int core_fn() { return ext_val() + 1; } diff --git a/tests/data/modules/partition_with_gmf/cfg.cppm b/tests/data/modules/partition_with_gmf/cfg.cppm new file mode 100644 index 00000000..b69c55a5 --- /dev/null +++ b/tests/data/modules/partition_with_gmf/cfg.cppm @@ -0,0 +1,2 @@ +export module Cfg; +export import :Limits; diff --git a/tests/data/modules/partition_with_gmf/config.h b/tests/data/modules/partition_with_gmf/config.h new file mode 100644 index 00000000..de43334c --- /dev/null +++ b/tests/data/modules/partition_with_gmf/config.h @@ -0,0 +1 @@ +#define MAX_SIZE 100 diff --git a/tests/data/modules/partition_with_gmf/part_cfg.cppm b/tests/data/modules/partition_with_gmf/part_cfg.cppm new file mode 100644 index 00000000..79b4eb55 --- /dev/null +++ b/tests/data/modules/partition_with_gmf/part_cfg.cppm @@ -0,0 +1,4 @@ +module; +#include "config.h" +export module Cfg:Limits; +export constexpr int max_size = MAX_SIZE; diff --git a/tests/data/modules/private_module_fragment/priv.cppm b/tests/data/modules/private_module_fragment/priv.cppm new file mode 100644 index 00000000..7acc46d9 --- /dev/null +++ b/tests/data/modules/private_module_fragment/priv.cppm @@ -0,0 +1,5 @@ +export module Priv; +export int public_fn(); +module : private; +int public_fn() { return 42; } +int private_helper() { return 7; } diff --git a/tests/data/modules/re_export/core.cppm b/tests/data/modules/re_export/core.cppm new file mode 100644 index 00000000..cdffe208 --- /dev/null +++ b/tests/data/modules/re_export/core.cppm @@ -0,0 +1,2 @@ +export module Core; +export int core_fn() { return 1; } diff --git a/tests/data/modules/re_export/user.cppm b/tests/data/modules/re_export/user.cppm new file mode 100644 index 00000000..bfa42f3b --- /dev/null +++ b/tests/data/modules/re_export/user.cppm @@ -0,0 +1,3 @@ +export module User; +import Wrapper; +export int use_fn() { return core_fn() + wrap_fn(); } diff --git a/tests/data/modules/re_export/wrapper.cppm b/tests/data/modules/re_export/wrapper.cppm new file mode 100644 index 00000000..2d9c6039 --- /dev/null +++ b/tests/data/modules/re_export/wrapper.cppm @@ -0,0 +1,3 @@ +export module Wrapper; +export import Core; +export int wrap_fn() { return core_fn() + 10; } diff --git a/tests/data/modules/save_recompile/leaf.cppm b/tests/data/modules/save_recompile/leaf.cppm new file mode 100644 index 00000000..17c57faa --- /dev/null +++ b/tests/data/modules/save_recompile/leaf.cppm @@ -0,0 +1,2 @@ +export module Leaf; +export int leaf() { return 1; } diff --git a/tests/data/modules/save_recompile/mid.cppm b/tests/data/modules/save_recompile/mid.cppm new file mode 100644 index 00000000..2c306523 --- /dev/null +++ b/tests/data/modules/save_recompile/mid.cppm @@ -0,0 +1,3 @@ +export module Mid; +import Leaf; +export int mid() { return leaf() + 1; } diff --git a/tests/data/modules/single_module_no_deps/mod_a.cppm b/tests/data/modules/single_module_no_deps/mod_a.cppm new file mode 100644 index 00000000..9823e0be --- /dev/null +++ b/tests/data/modules/single_module_no_deps/mod_a.cppm @@ -0,0 +1,2 @@ +export module A; +export int foo() { return 42; } diff --git a/tests/data/modules/template_export/tmpl.cppm b/tests/data/modules/template_export/tmpl.cppm new file mode 100644 index 00000000..afed2ed0 --- /dev/null +++ b/tests/data/modules/template_export/tmpl.cppm @@ -0,0 +1,5 @@ +export module Tmpl; +export template +T identity(T x) { return x; } +export template +auto pair_sum(T a, U b) { return a + b; } diff --git a/tests/data/modules/template_export/use_tmpl.cppm b/tests/data/modules/template_export/use_tmpl.cppm new file mode 100644 index 00000000..427bb1c4 --- /dev/null +++ b/tests/data/modules/template_export/use_tmpl.cppm @@ -0,0 +1,3 @@ +export module UseTmpl; +import Tmpl; +export int test() { return identity(42) + pair_sum(1, 2); } diff --git a/tests/integration/test_modules.py b/tests/integration/test_modules.py new file mode 100644 index 00000000..6f043ad2 --- /dev/null +++ b/tests/integration/test_modules.py @@ -0,0 +1,624 @@ +"""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 asyncio +import json +import shutil +from pathlib import Path + +import pytest +from lsprotocol.types import ( + ClientCapabilities, + DidCloseTextDocumentParams, + DidOpenTextDocumentParams, + HoverParams, + InitializeParams, + InitializedParams, + Position, + TextDocumentIdentifier, + TextDocumentItem, + WorkspaceFolder, +) + +# Directory containing pre-written module source files for each test case. +_DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "modules" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write_cdb(workspace: Path, files: list[str], extra_args: list[str] | None = None): + """Generate compile_commands.json for the given source files.""" + _write_cdb_entries(workspace, [(f, extra_args or []) for f in files]) + + +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((workspace / filename).as_posix()) + cdb.append( + { + "directory": workspace.as_posix(), + "file": (workspace / filename).as_posix(), + "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. + # Use a generous sleep to avoid flaky failures on slow CI machines. + await asyncio.sleep(2.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): + """A single module with no imports should compile without errors.""" + ws = _DATA_DIR / "single_module_no_deps" + _write_cdb(ws, ["mod_a.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Opening a module that imports another should trigger dependency compilation.""" + ws = _DATA_DIR / "chained_modules" + _write_cdb(ws, ["mod_a.cppm", "mod_b.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Diamond dependency graph should compile correctly.""" + ws = _DATA_DIR / "diamond_modules" + _write_cdb(ws, ["base.cppm", "left.cppm", "right.cppm", "top.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Dotted module names should work correctly.""" + ws = _DATA_DIR / "dotted_module_name" + _write_cdb(ws, ["io.cppm", "app.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """A module implementation unit should compile using the interface PCM.""" + ws = _DATA_DIR / "module_implementation_unit" + _write_cdb(ws, ["greeter.cppm", "greeter_impl.cpp"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """A regular .cpp file that imports a module should get PCM deps compiled.""" + ws = _DATA_DIR / "consumer_imports_module" + _write_cdb(ws, ["math.cppm", "main.cpp"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Module partitions should be compiled in correct order.""" + ws = _DATA_DIR / "module_partitions" + _write_cdb(ws, ["part_a.cppm", "part_b.cppm", "lib.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """A single partition interface re-exported from primary should compile.""" + ws = _DATA_DIR / "partition_interface" + _write_cdb(ws, ["part.cppm", "primary.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Partition importing another partition within same module.""" + ws = _DATA_DIR / "partition_chain" + _write_cdb(ws, ["types.cppm", "core.cppm", "sys.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Re-exported module symbols should be accessible through the wrapper.""" + ws = _DATA_DIR / "re_export" + _write_cdb(ws, ["core.cppm", "wrapper.cppm", "user.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Module with export block syntax should compile correctly.""" + ws = _DATA_DIR / "export_block" + _write_cdb(ws, ["block.cppm", "consumer.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Module with global module fragment (#include before module decl).""" + ws = _DATA_DIR / "global_module_fragment" + _write_cdb(ws, ["gmf.cppm"], extra_args=["-I", ws.as_posix()]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Module with private module fragment should compile correctly.""" + ws = _DATA_DIR / "private_module_fragment" + _write_cdb(ws, ["priv.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Module with exported namespace should compile correctly.""" + ws = _DATA_DIR / "export_namespace" + _write_cdb(ws, ["ns.cppm", "calc.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Module with GMF (#include) + import should compile correctly.""" + ws = _DATA_DIR / "gmf_with_import" + _write_cdb_entries( + ws, + [ + ("base.cppm", []), + ("combined.cppm", ["-I", ws.as_posix()]), + ], + ) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Two independent modules should each compile without errors.""" + ws = _DATA_DIR / "independent_modules" + _write_cdb(ws, ["x.cppm", "y.cppm"]) + await _init(client, ws) + + uri_x, _ = await _open_and_wait(client, ws, "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, ws, "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): + """Module with exported templates should compile correctly.""" + ws = _DATA_DIR / "template_export" + _write_cdb(ws, ["tmpl.cppm", "use_tmpl.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Exported class with cross-module inheritance should compile.""" + ws = _DATA_DIR / "class_export_and_inheritance" + _write_cdb(ws, ["shape.cppm", "circle.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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.""" + # This test mutates source files at runtime, so copy data to tmp_path. + src = _DATA_DIR / "save_recompile" + for f in src.iterdir(): + if f.is_file(): + shutil.copy2(f, tmp_path / f.name) + + _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): + """A module with an error should produce diagnostics.""" + ws = _DATA_DIR / "module_compile_error" + _write_cdb(ws, ["good.cppm", "bad.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "bad.cppm") + diags = client.diagnostics.get(uri, []) + assert len(diags) > 0, "Expected diagnostics for undefined symbol" + # The error should be on line 2 (0-indexed) where UNDEFINED_SYMBOL is used. + error_diag = diags[0] + assert error_diag.range.start.line == 2, ( + f"Expected error on line 2, got line {error_diag.range.start.line}" + ) + # Severity 1 = Error in LSP spec. + assert error_diag.severity == 1, ( + f"Expected severity Error (1), got {error_diag.severity}" + ) + + +# --------------------------------------------------------------------------- +# Deep chain (5 modules) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_deep_chain(client): + """A 5-level module chain should compile correctly.""" + ws = _DATA_DIR / "deep_chain" + _write_cdb(ws, ["m1.cppm", "m2.cppm", "m3.cppm", "m4.cppm", "m5.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Partition with GMF (#include) should compile correctly.""" + ws = _DATA_DIR / "partition_with_gmf" + _write_cdb_entries( + ws, + [ + ("part_cfg.cppm", ["-I", ws.as_posix()]), + ("cfg.cppm", []), + ], + ) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Partition importing an external module should compile correctly.""" + ws = _DATA_DIR / "partition_with_external_import" + _write_cdb(ws, ["ext.cppm", "part.cppm", "app.cppm"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "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): + """Hover on a symbol imported from a module should return info.""" + ws = _DATA_DIR / "hover_on_imported_symbol" + _write_cdb(ws, ["defs.cppm", "use.cpp"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "use.cpp") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + # Hover on 'magic_number' (line 3, character 11 = start of 'magic_number()') + hover = await client.text_document_hover_async( + HoverParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=3, character=11), + ) + ) + assert hover is not None, "Hover on imported symbol should return info" + assert hover.contents is not None + + +# --------------------------------------------------------------------------- +# Plain C++ file with no modules (compile_graph == null path) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_no_modules_plain_cpp(client): + """A plain C++ file with no modules should compile normally (no CompileGraph).""" + ws = _DATA_DIR / "no_modules_plain_cpp" + _write_cdb(ws, ["plain.cpp"]) + await _init(client, ws) + + uri, _ = await _open_and_wait(client, ws, "plain.cpp") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected no diagnostics, got: {diags}" + + +# --------------------------------------------------------------------------- +# Circular module dependency (cycle detection) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_circular_module_dependency(client): + """Circular module imports should not hang the server. + + When modules form a cycle (CycA imports CycB, CycB imports CycA), + the CompileGraph's cycle detection should prevent deadlock. The PCM + builds will fail, so the server may skip the final compilation and + never publish diagnostics. The key assertion is that the server + remains responsive — we verify this by successfully performing a + subsequent operation (opening a non-cyclic file). + """ + ws = _DATA_DIR / "circular_module_dependency" + _write_cdb(ws, ["cycle_a.cppm", "cycle_b.cppm", "ok.cppm"]) + await _init(client, ws) + + # Open a cyclic file — the server should not hang. + _open(client, ws, "cycle_a.cppm") + # Give the server time to attempt (and fail) the cyclic PCM builds. + await asyncio.sleep(5.0) + + # Verify the server is still responsive by opening a non-cyclic file. + uri_ok, _ = await _open_and_wait(client, ws, "ok.cppm") + diags = client.diagnostics.get(uri_ok, []) + assert len(diags) == 0, ( + f"Non-cyclic module should compile fine after cycle attempt, got: {diags}" + ) diff --git a/tests/unit/feature/document_link_tests.cpp b/tests/unit/feature/document_link_tests.cpp index 2a9067a8..ecdd8ab2 100644 --- a/tests/unit/feature/document_link_tests.cpp +++ b/tests/unit/feature/document_link_tests.cpp @@ -26,7 +26,7 @@ void run(llvm::StringRef source) { auto to_local_range(const protocol::Range& range) -> LocalSourceRange { feature::PositionMapper converter(tester.unit->interested_content(), feature::PositionEncoding::UTF8); - return LocalSourceRange(converter.to_offset(range.start), converter.to_offset(range.end)); + return LocalSourceRange(*converter.to_offset(range.start), *converter.to_offset(range.end)); } void expect_link(std::size_t index, llvm::StringRef name, llvm::StringRef path) { diff --git a/tests/unit/feature/folding_range_tests.cpp b/tests/unit/feature/folding_range_tests.cpp index d8759987..23e52385 100644 --- a/tests/unit/feature/folding_range_tests.cpp +++ b/tests/unit/feature/folding_range_tests.cpp @@ -52,7 +52,7 @@ auto to_local_range(const protocol::FoldingRange& range) -> LocalSourceRange { .character = range.end_character.value_or(0), }; - return LocalSourceRange(converter.to_offset(start), converter.to_offset(end)); + return LocalSourceRange(*converter.to_offset(start), *converter.to_offset(end)); } void expect_folding(std::uint32_t index, diff --git a/tests/unit/feature/inlay_hint_tests.cpp b/tests/unit/feature/inlay_hint_tests.cpp index 5f9cfce6..a88d24e8 100644 --- a/tests/unit/feature/inlay_hint_tests.cpp +++ b/tests/unit/feature/inlay_hint_tests.cpp @@ -28,7 +28,7 @@ void run(llvm::StringRef code, std::source_location location = std::source_locat feature::PositionMapper converter(tester.unit->interested_content(), feature::PositionEncoding::UTF8); for(auto& hint: hints) { - hints_map[converter.to_offset(hint.position)] = hint; + hints_map[*converter.to_offset(hint.position)] = hint; } if(!tester.unit->diagnostics().empty()) { 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 00000000..bf5cd23e --- /dev/null +++ b/tests/unit/server/compile_graph_integration_tests.cpp @@ -0,0 +1,1229 @@ +#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.contains(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.contains(pid_a)); + EXPECT_TRUE(env.pcm_paths.contains(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" + R"(#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.contains(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.contains(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" + R"(#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" + R"(#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.contains(pid_good)); + // Bad module should NOT have a PCM. + EXPECT_FALSE(env.pcm_paths.contains(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" R"(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.contains(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 3fc80f77..b57b4619 100644 --- a/tests/unit/server/compile_graph_tests.cpp +++ b/tests/unit/server/compile_graph_tests.cpp @@ -44,6 +44,13 @@ inline CompileGraph::dispatch_fn failing_dispatch() { }; } +/// Dispatch that fails only for specific path_ids. +inline CompileGraph::dispatch_fn selective_dispatch(llvm::DenseSet fail_ids) { + return [fail_ids = std::move(fail_ids)](std::uint32_t path_id) -> et::task { + co_return !fail_ids.contains(path_id); + }; +} + TEST_SUITE(CompileGraph) { TEST_CASE(CompileNoDeps) { @@ -615,6 +622,47 @@ TEST_CASE(UpdateDuringCompile) { EXPECT_TRUE(graph.is_dirty(1)); } +TEST_CASE(WhenAllPartialFailure) { + et::event_loop loop; + // 1 -> {2, 3}. Only unit 3 fails. + CompileGraph graph(selective_dispatch({ + 3 + }), + static_resolver({{1, {2, 3}}})); + + auto test = [this, &graph]() -> et::task<> { + auto result = co_await graph.compile(1).catch_cancel(); + EXPECT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + // Unit 2 succeeded — should be clean. + EXPECT_FALSE(graph.is_dirty(2)); + // Unit 3 failed — stays dirty. + EXPECT_TRUE(graph.is_dirty(3)); + // Unit 1 was not dispatched — stays dirty. + EXPECT_TRUE(graph.is_dirty(1)); + }; + + auto t = test(); + loop.schedule(t); + loop.run(); +} + +TEST_CASE(UpdateUnknownPathId) { + CompileGraph graph(instant_dispatch(), no_deps()); + + // update on a path_id that was never compiled should not crash. + auto dirtied = graph.update(999); + EXPECT_EQ(dirtied.size(), 0u); + EXPECT_FALSE(graph.has_unit(999)); +} + +TEST_CASE(EmptyGraphNoCompile) { + // Construct and destroy without any compile calls. + CompileGraph graph(instant_dispatch(), no_deps()); + EXPECT_FALSE(graph.has_unit(1)); + graph.cancel_all(); // Should not crash on empty graph. +} + }; // TEST_SUITE(CompileGraph) } // namespace diff --git a/tests/unit/server/module_worker_tests.cpp b/tests/unit/server/module_worker_tests.cpp new file mode 100644 index 00000000..afa5ae99 --- /dev/null +++ b/tests/unit/server/module_worker_tests.cpp @@ -0,0 +1,275 @@ +#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" R"(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