diff --git a/src/server/master_server.cpp b/src/server/master_server.cpp index 57c6e27a0..e43ba4165 100644 --- a/src/server/master_server.cpp +++ b/src/server/master_server.cpp @@ -20,6 +20,7 @@ #include "syntax/dependency_graph.h" #include "syntax/scan.h" +#include "llvm/Support/Chrono.h" #include "llvm/Support/raw_ostream.h" #include "llvm/Support/xxhash.h" @@ -31,6 +32,62 @@ namespace refl = eventide::refl; using et::ipc::RequestResult; using RequestContext = et::ipc::JsonPeer::RequestContext; +/// Hash a file's content using xxh3_64bits. Returns 0 on read failure. +static std::uint64_t hash_file(llvm::StringRef path) { + auto buf = llvm::MemoryBuffer::getFile(path); + if(!buf) + return 0; + return llvm::xxh3_64bits((*buf)->getBuffer()); +} + +/// Capture a two-layer staleness snapshot after a successful compilation. +/// Interns dependency paths into the PathPool and hashes each file's content. +static DepsSnapshot capture_deps_snapshot(PathPool& pool, llvm::ArrayRef deps) { + DepsSnapshot snap; + // Capture timestamp BEFORE hashing to avoid TOCTOU: if a file is modified + // during hashing, its mtime will be > build_at, triggering Layer 2 re-hash. + snap.build_at = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + snap.path_ids.reserve(deps.size()); + snap.hashes.reserve(deps.size()); + for(const auto& file: deps) { + snap.path_ids.push_back(pool.intern(file)); + snap.hashes.push_back(hash_file(file)); + } + return snap; +} + +/// Two-layer staleness check. +/// +/// Layer 1 (fast): stat each dep file, compare mtime against build_at. +/// If all mtimes <= build_at → nothing changed, return false immediately. +/// +/// Layer 2 (precise): for files with mtime > build_at, re-hash their content. +/// If the hash matches the stored hash → file was touched but not modified. +/// If any hash differs → truly changed, return true. +static bool deps_changed(const PathPool& pool, const DepsSnapshot& snap) { + for(std::size_t i = 0; i < snap.path_ids.size(); ++i) { + auto path = pool.resolve(snap.path_ids[i]); + llvm::sys::fs::file_status status; + if(auto ec = llvm::sys::fs::status(path, status)) { + // File disappeared — definitely changed. + if(snap.hashes[i] != 0) + return true; + continue; + } + + // Layer 1: mtime check (cheap, stat only). + auto current_mtime = llvm::sys::toTimeT(status.getLastModificationTime()); + if(current_mtime <= snap.build_at) + continue; + + // Layer 2: mtime is newer — re-hash content to confirm actual change. + auto current_hash = hash_file(path); + if(current_hash != snap.hashes[i]) + return true; + } + return false; +} + 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)) {} @@ -270,34 +327,34 @@ et::task MasterServer::ensure_pch(std::uint32_t path_id, auto bound = compute_preamble_bound(text); if(bound == 0) { // No preamble directives — PCH would be empty. Clear any stale entry. - if(auto old_it = pch_paths.find(path_id); old_it != pch_paths.end()) { - fs::remove(old_it->second); + auto it = pch_states.find(path_id); + if(it != pch_states.end()) { + fs::remove(it->second.path); + pch_states.erase(it); } - pch_paths.erase(path_id); - pch_bounds.erase(path_id); - pch_hashes.erase(path_id); co_return true; } auto preamble_hash = llvm::xxh3_64bits(llvm::StringRef(text).substr(0, bound)); - // Reuse existing PCH if preamble content hasn't changed. - if(auto it = pch_hashes.find(path_id); it != pch_hashes.end()) { - if(it->second == preamble_hash && pch_paths.contains(path_id)) { - pch_bounds[path_id] = bound; + // Reuse existing PCH if preamble content and deps haven't changed. + if(auto it = pch_states.find(path_id); it != pch_states.end()) { + auto& st = it->second; + if(st.hash == preamble_hash && !st.path.empty() && !deps_changed(path_pool, st.deps)) { + st.bound = bound; co_return true; } } // If another coroutine is already building PCH for this file, wait for it. - if(auto it = pch_building.find(path_id); it != pch_building.end()) { - co_await it->second->wait(); - co_return pch_paths.contains(path_id); + if(auto it = pch_states.find(path_id); it != pch_states.end() && it->second.building) { + co_await it->second.building->wait(); + co_return !pch_states[path_id].path.empty(); } // Register in-flight build so concurrent requests wait on us. auto completion = std::make_shared(); - pch_building[path_id] = completion; + pch_states[path_id].building = completion; // Build a new PCH via stateless worker. worker::BuildPCHParams pch_params; @@ -315,24 +372,25 @@ et::task MasterServer::ensure_pch(std::uint32_t path_id, LOG_WARN("PCH build failed for {}: {}", path, result.has_value() ? result.value().error : result.error().message); - pch_building.erase(path_id); + pch_states[path_id].building.reset(); completion->set(); co_return false; } // Delete old PCH temp file before replacing. - if(auto old_it = pch_paths.find(path_id); old_it != pch_paths.end()) { - fs::remove(old_it->second); + auto& st = pch_states[path_id]; + if(!st.path.empty()) { + fs::remove(st.path); } - pch_paths[path_id] = result.value().pch_path; - pch_bounds[path_id] = bound; - pch_hashes[path_id] = preamble_hash; + st.path = result.value().pch_path; + st.bound = bound; + st.hash = preamble_hash; + st.deps = capture_deps_snapshot(path_pool, result.value().deps); + st.building.reset(); LOG_INFO("PCH built for {}: {}", path, result.value().pch_path); - // Signal waiters after state is fully updated, then remove in-flight entry. - pch_building.erase(path_id); completion->set(); co_return true; } @@ -355,8 +413,8 @@ et::task MasterServer::ensure_deps(std::uint32_t path_id, // Build or reuse PCH. auto pch_ok = co_await ensure_pch(path_id, path, text, directory, arguments); if(pch_ok) { - if(auto pch_it = pch_paths.find(path_id); pch_it != pch_paths.end()) { - pch = {pch_it->second, pch_bounds[path_id]}; + if(auto pch_it = pch_states.find(path_id); pch_it != pch_states.end()) { + pch = {pch_it->second.path, pch_it->second.bound}; } } @@ -410,9 +468,27 @@ et::task MasterServer::ensure_compiled(std::uint32_t path_id) { auto& doc = it->second; - // Fast path: AST is already up-to-date, nothing to do. + // Fast path: AST was previously compiled successfully. + // Check if any dependency file has changed since the last compilation. + // We check both AST deps (body includes) and PCH deps (preamble includes), + // because when PCH is active the preamble headers are baked into the PCH + // and won't appear in the AST's directive list. if(!doc.ast_dirty) { - co_return true; + bool changed = false; + auto ast_deps_it = ast_deps.find(path_id); + if(ast_deps_it != ast_deps.end() && deps_changed(path_pool, ast_deps_it->second)) { + changed = true; + } + if(!changed) { + auto pch_it = pch_states.find(path_id); + if(pch_it != pch_states.end() && deps_changed(path_pool, pch_it->second.deps)) { + changed = true; + } + } + if(!changed) { + co_return true; + } + doc.ast_dirty = true; } // Snapshot the generation counter *before* any co_await. After compilation @@ -502,6 +578,7 @@ et::task MasterServer::ensure_compiled(std::uint32_t path_id) { publish_diagnostics(uri_str, doc2.version, result.value().diagnostics); doc2.ast_dirty = false; + ast_deps[path_id] = capture_deps_snapshot(path_pool, result.value().deps); schedule_indexing(); co_return true; } @@ -975,7 +1052,7 @@ protocol::SymbolKind MasterServer::to_lsp_symbol_kind(SymbolKind kind) { } } -et::task> +et::task> MasterServer::lookup_symbol_at_position(const std::string& uri, const protocol::Position& position) { auto path = uri_to_path(uri); @@ -1113,7 +1190,7 @@ protocol::TypeHierarchyItem MasterServer::build_type_hierarchy_item(const Symbol return item; } -et::task> +et::task> MasterServer::resolve_hierarchy_item(const std::string& uri, const protocol::Range& range, const std::optional& data) { @@ -1344,9 +1421,8 @@ void MasterServer::register_handlers() { } documents.erase(path_id); - pch_paths.erase(path_id); - pch_bounds.erase(path_id); - pch_hashes.erase(path_id); + pch_states.erase(path_id); + ast_deps.erase(path_id); // Clear diagnostics for closed file clear_diagnostics(params.text_document.uri); @@ -1378,16 +1454,6 @@ void MasterServer::register_handlers() { } } - // Invalidate all cached PCH hashes — the saved file may be a header - // included by other TUs, so we must force rebuild for all open documents. - pch_hashes.clear(); - - // A saved header may be included by any open TU. Since pch_hashes - // were cleared, all cached ASTs are potentially stale. - for(auto& [_, doc]: documents) { - doc.ast_dirty = true; - } - // Trigger background indexing after save. schedule_indexing(); diff --git a/src/server/master_server.h b/src/server/master_server.h index a4b804878..3a0f2e081 100644 --- a/src/server/master_server.h +++ b/src/server/master_server.h @@ -30,11 +30,73 @@ namespace protocol = et::ipc::protocol; struct DocumentState { int version = 0; + std::string text; + std::uint64_t generation = 0; + bool ast_dirty = true; }; +/// Two-layer staleness snapshot for compilation artifacts (PCH, AST, etc.). +/// +/// Layer 1 (fast): compare each file's current mtime against build_at. +/// If all mtimes <= build_at, the artifact is fresh (zero I/O beyond stat). +/// +/// Layer 2 (precise): for files whose mtime changed, re-hash their content +/// and compare against the stored hash. If the hash matches, the file was +/// "touched" but not actually modified — skip the rebuild. +/// +/// This avoids unnecessary recompilation from timestamp-only changes (e.g. +/// git checkout, touch, backup restore) while remaining cheap in the common +/// case where nothing changed. +struct DepsSnapshot { + /// File path IDs interned via PathPool. + llvm::SmallVector path_ids; + + /// xxh3_64bits of file content at build time. + llvm::SmallVector hashes; + + /// time_t when this snapshot was captured. + std::int64_t build_at = 0; +}; + +/// Cached PCH state for a single source file. +struct PCHState { + /// Built PCH file path. + std::string path; + + /// Preamble byte offset used when building. + std::uint32_t bound = 0; + + /// xxh3 hash of preamble content. + std::uint64_t hash = 0; + + /// Dependency snapshot for staleness detection. + DepsSnapshot deps; + + /// Non-null while a build is in flight. + std::shared_ptr building; +}; + +/// Information about a symbol at a given position. +struct SymbolInfo { + /// Unique hash identifying this symbol across the project. + index::SymbolHash hash = 0; + + /// Human-readable symbol name. + std::string name; + + /// Symbol kind (function, class, variable, etc.). + SymbolKind kind; + + /// URI of the file containing this symbol. + std::string uri; + + /// Source range of the symbol's identifier. + protocol::Range range; +}; + enum class ServerLifecycle : std::uint8_t { Uninitialized, Initialized, @@ -51,95 +113,110 @@ class MasterServer { void register_handlers(); private: + /// Event loop for scheduling async tasks. et::event_loop& loop; + + /// JSON-RPC peer for LSP communication. et::ipc::JsonPeer& peer; + + /// Pool of stateful/stateless worker processes. WorkerPool pool; + + /// Interning pool for file paths (path string -> uint32_t ID). PathPool path_pool; + + /// Current server lifecycle state. ServerLifecycle lifecycle = ServerLifecycle::Uninitialized; + /// Path to the clice binary itself. std::string self_path; + + /// Root directory of the opened workspace. std::string workspace_root; + + /// User/project configuration. CliceConfig config; + /// Compilation database (compile_commands.json). CompilationDatabase cdb; + + /// Include/module dependency graph built from fast lexer scanning. DependencyGraph dependency_graph; - // Module compilation graph (lazy dependency resolution). + /// Module compilation graph (lazy dependency resolution). std::unique_ptr compile_graph; - // path_id -> built PCM output path (set after successful module build). + /// 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). + /// path_id -> module name (for files that provide a module interface). llvm::DenseMap path_to_module; - // path_id -> built PCH file path. - llvm::DenseMap pch_paths; - - // path_id -> preamble bound (byte offset) used when building the PCH. - llvm::DenseMap pch_bounds; - - // path_id -> hash of preamble content at PCH build time (for staleness detection). - llvm::DenseMap pch_hashes; - - // path_id -> in-flight PCH build event (later arrivals co_await the same build). - llvm::DenseMap> pch_building; + /// path_id -> cached PCH state (path, preamble hash, deps, build event). + llvm::DenseMap pch_states; // === Index state === - // Global symbol table and path mapping for the project. + /// Global symbol table and path mapping for the project. index::ProjectIndex project_index; - // Per-file merged index shards (keyed by project-level path_id). + /// Per-file merged index shards (keyed by project-level path_id). llvm::DenseMap merged_indices; - // Files queued for background indexing (server-level path_ids from CDB). + /// Files queued for background indexing (server-level path_ids from CDB). std::vector index_queue; - // Index of next file to process in index_queue. + /// Index of next file to process in index_queue. std::size_t index_queue_pos = 0; - // Whether background indexing is currently in progress. + /// Whether background indexing is currently in progress. bool indexing_active = false; - // Whether a background indexing coroutine has been scheduled (waiting on timer). + /// Whether a background indexing coroutine has been scheduled (waiting on timer). bool indexing_scheduled = false; - // Timer for idle-triggered background indexing. + /// Timer for idle-triggered background indexing. std::shared_ptr index_idle_timer; - // Document state: path_id -> DocumentState + /// path_id -> open document state (text, version, generation, dirty flag). llvm::DenseMap documents; - // Helper: convert URI to file path + /// Per-file dependency snapshots from last successful AST compilation. + llvm::DenseMap ast_deps; + + // === Helpers === + + /// Convert a file:// URI to a local file path. std::string uri_to_path(const std::string& uri); - // Publish diagnostics to client + /// Publish diagnostics to the LSP client. void publish_diagnostics(const std::string& uri, int version, const eventide::serde::RawValue& diagnostics_json); + + /// Clear diagnostics for a file (publish empty array). void clear_diagnostics(const std::string& uri); - // Ensure a file has been compiled before servicing feature requests + /// Pull-based compilation entry point. Ensures AST is up-to-date. et::task ensure_compiled(std::uint32_t path_id); - // Load CDB and build initial include graph + /// Load CDB and build initial include/module dependency graph. et::task<> load_workspace(); - // Helper: fill compile arguments from CDB into worker params + /// Fill compile arguments from CDB for a given file. bool fill_compile_args(llvm::StringRef path, std::string& directory, std::vector& arguments); - // Build or reuse PCH for a source file. Returns true if PCH is available. + /// Build or reuse PCH for a source file. Returns true if PCH is available. et::task ensure_pch(std::uint32_t path_id, llvm::StringRef path, const std::string& text, const std::string& directory, const std::vector& arguments); - // Compile module dependencies, build/reuse PCH, and fill PCM paths into - // the given fields. Shared by ensure_compiled() and forward_stateless(). + /// Compile module dependencies, build/reuse PCH, and fill PCM paths. + /// Shared preparation step for ensure_compiled() and forward_stateless(). et::task ensure_deps(std::uint32_t path_id, llvm::StringRef path, const std::string& text, @@ -148,22 +225,23 @@ class MasterServer { std::pair& pch, std::unordered_map& pcms); - // Schedule background indexing when idle. + /// Schedule background indexing when idle. void schedule_indexing(); - // Background indexing coroutine: picks files from queue and dispatches to workers. + /// Background indexing coroutine: picks files from queue and dispatches to workers. et::task<> run_background_indexing(); - // Merge a TUIndex result into ProjectIndex and MergedIndex shards. + /// Merge a TUIndex result into ProjectIndex and MergedIndex shards. void merge_index_result(const void* tu_index_data, std::size_t size); - // Persist index state to disk. + /// Persist index state to disk. void save_index(); - // Load index state from disk. + /// Load index state from disk. void load_index(); - // Forwarding helpers for feature requests (RawValue passthrough) + // === Feature request forwarding === + using RawResult = et::task; /// Forward a simple stateful request (path-only worker params). @@ -178,21 +256,13 @@ class MasterServer { template RawResult forward_stateless(const std::string& uri, const protocol::Position& position); + // === Index query helpers === + /// Query index for symbol relations (GoToDefinition, FindReferences, etc.). - /// Returns LSP Location array as RawValue. RawResult query_index_relations(const std::string& uri, const protocol::Position& position, RelationKind kind); - /// Information about a symbol at a given position. - struct SymbolInfo { - index::SymbolHash hash = 0; - std::string name; - SymbolKind kind; - std::string uri; - protocol::Range range; - }; - /// Look up a symbol at a position, returning its hash, name, kind, and range. et::task> lookup_symbol_at_position(const std::string& uri, const protocol::Position& position); @@ -210,7 +280,6 @@ class MasterServer { protocol::TypeHierarchyItem build_type_hierarchy_item(const SymbolInfo& info); /// Resolve SymbolInfo from a hierarchy item's stored data (symbol hash). - /// Falls back to position-based lookup if data is missing. et::task> resolve_hierarchy_item(const std::string& uri, const protocol::Range& range, diff --git a/src/server/protocol.h b/src/server/protocol.h index 2afe86e3d..5567f6ea5 100644 --- a/src/server/protocol.h +++ b/src/server/protocol.h @@ -32,6 +32,7 @@ struct CompileResult { /// Diagnostics serialized as JSON (RawValue) to avoid bincode/serde annotation conflicts. eventide::serde::RawValue diagnostics; std::size_t memory_usage; + std::vector deps; }; struct HoverParams { @@ -104,6 +105,7 @@ struct BuildPCHResult { bool success; std::string error; std::string pch_path; + std::vector deps; }; struct BuildPCMParams { @@ -118,6 +120,7 @@ struct BuildPCMResult { bool success; std::string error; std::string pcm_path; + std::vector deps; }; struct IndexParams { diff --git a/src/server/stateful_worker.cpp b/src/server/stateful_worker.cpp index 6b8d47207..b6b380dba 100644 --- a/src/server/stateful_worker.cpp +++ b/src/server/stateful_worker.cpp @@ -204,6 +204,9 @@ void StatefulWorker::register_handlers() { LOG_WARN("Compile incomplete: path={}, {}ms", params.path, timer.ms()); } result.memory_usage = 0; // TODO: query actual memory + if(doc.unit.completed()) { + result.deps = doc.unit.deps(); + } return result; }); diff --git a/src/server/stateless_worker.cpp b/src/server/stateless_worker.cpp index f11dded7c..87f421f69 100644 --- a/src/server/stateless_worker.cpp +++ b/src/server/stateless_worker.cpp @@ -91,7 +91,9 @@ int run_stateless_worker_mode() { params.file, cp.output_file, timer.ms()); - return {true, "", std::string(cp.output_file)}; + worker::BuildPCHResult pch_result{true, "", std::string(cp.output_file)}; + pch_result.deps = pch_info.deps; + return pch_result; } else { LOG_WARN("BuildPCH failed: file={}, {}ms", params.file, timer.ms()); fs::remove(cp.output_file); @@ -129,7 +131,9 @@ int run_stateless_worker_mode() { if(unit.completed()) { LOG_INFO("BuildPCM done: module={}, {}ms", params.module_name, timer.ms()); - return {true, "", std::string(cp.output_file)}; + worker::BuildPCMResult pcm_result{true, "", std::string(cp.output_file)}; + pcm_result.deps = pcm_info.deps; + return pcm_result; } else { LOG_WARN("BuildPCM failed: module={}, {}ms", params.module_name, timer.ms()); return {false, "PCM compilation failed", ""}; diff --git a/tests/integration/test_staleness.py b/tests/integration/test_staleness.py new file mode 100644 index 000000000..af3c832ed --- /dev/null +++ b/tests/integration/test_staleness.py @@ -0,0 +1,170 @@ +"""Integration tests for mtime-based staleness tracking. + +Verifies that ensure_compiled() and ensure_pch() detect dependency file +changes via mtime snapshots, triggering recompilation without relying +on didSave to mark everything dirty. +""" + +import asyncio +import json +import os +import shutil + +import pytest +from lsprotocol.types import ( + DidSaveTextDocumentParams, + HoverParams, + Position, + TextDocumentIdentifier, +) + + +def _write_cdb(workspace, files, extra_args=None): + """Write a compile_commands.json for the given source files.""" + entries = [] + for f in files: + args = ["clang++", "-std=c++17", "-fsyntax-only"] + if extra_args: + args.extend(extra_args) + args.append(str(workspace / f)) + entries.append( + { + "directory": str(workspace), + "file": str(workspace / f), + "arguments": args, + } + ) + (workspace / "compile_commands.json").write_text(json.dumps(entries, indent=2)) + + +def _doc(uri: str) -> TextDocumentIdentifier: + return TextDocumentIdentifier(uri=uri) + + +async def test_header_change_invalidates_ast(client, tmp_path): + """Modifying a header on disk should cause recompilation on next hover, + even though didSave was never called (mtime-based detection).""" + # Setup: main.cpp includes header.h + (tmp_path / "header.h").write_text("inline int value() { return 1; }\n") + (tmp_path / "main.cpp").write_text( + '#include "header.h"\nint main() { return value(); }\n' + ) + _write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + # First compile — should succeed with no diagnostics. + uri, _ = await client.open_and_wait(tmp_path / "main.cpp") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0, f"Expected clean compile, got: {diags}" + + # Modify header on disk — introduce an error. + # Sleep briefly to ensure mtime changes (filesystem granularity). + # Ensure mtime advances past filesystem granularity (1s on some FSes). + await asyncio.sleep(1.1) + (tmp_path / "header.h").write_text( + "inline int value() { return }\n" + ) # syntax error + + # Send another hover — ensure_compiled should detect mtime change + # in deps and trigger recompilation. The recompilation publishes + # fresh diagnostics as a side effect. + event = client.wait_for_diagnostics(uri) + await client.text_document_hover_async( + HoverParams(text_document=_doc(uri), position=Position(line=0, character=0)) + ) + await asyncio.wait_for(event.wait(), timeout=60.0) + + # Should now have diagnostics from the broken header. + diags = client.diagnostics.get(uri, []) + assert len(diags) > 0, "Expected diagnostics after header change" + + +async def test_header_change_invalidates_pch(client, tmp_path): + """Modifying a preamble header on disk should trigger PCH rebuild.""" + (tmp_path / "header.h").write_text("#pragma once\nstruct Foo { int x; };\n") + (tmp_path / "main.cpp").write_text( + '#include "header.h"\nint main() { Foo f; return f.x; }\n' + ) + _write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + # First compile — success. + uri, _ = await client.open_and_wait(tmp_path / "main.cpp") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0 + + # Modify header — rename struct field. + # Ensure mtime advances past filesystem granularity (1s on some FSes). + await asyncio.sleep(1.1) + (tmp_path / "header.h").write_text( + "#pragma once\nstruct Foo { int y; };\n" # x -> y + ) + + # Hover again — PCH should rebuild, AST should recompile. + # main.cpp uses f.x which no longer exists → diagnostics expected. + event = client.wait_for_diagnostics(uri) + await client.text_document_hover_async( + HoverParams(text_document=_doc(uri), position=Position(line=0, character=0)) + ) + await asyncio.wait_for(event.wait(), timeout=30.0) + + diags = client.diagnostics.get(uri, []) + assert len(diags) > 0, "Expected error after header field rename" + + +async def test_no_change_skips_recompile(client, tmp_path): + """When no dependency has changed, ensure_compiled should fast-path.""" + (tmp_path / "main.cpp").write_text("int main() { return 0; }\n") + _write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + uri, _ = await client.open_and_wait(tmp_path / "main.cpp") + diags = client.diagnostics.get(uri, []) + assert len(diags) == 0 + + # Second hover — should use cached AST (no recompilation). + # Verify it returns quickly and doesn't crash. + hover = await client.text_document_hover_async( + HoverParams(text_document=_doc(uri), position=Position(line=0, character=4)) + ) + # "main" should be hoverable. + assert hover is not None + + +async def test_didsave_with_module_deps(client, test_data_dir, tmp_path): + """didSave on a module file should invalidate CompileGraph dependents.""" + src = test_data_dir / "modules" / "save_recompile" + for f in src.iterdir(): + if f.is_file(): + shutil.copy2(f, tmp_path / f.name) + + from tests.conftest import generate_cdb + + generate_cdb(tmp_path) + await client.initialize(tmp_path) + + # Open and compile Mid (which imports Leaf). + mid_uri, _ = await client.open_and_wait(tmp_path / "mid.cppm") + diags = client.diagnostics.get(mid_uri, []) + assert len(diags) == 0 + + # Modify Leaf on disk and send didSave — should invalidate Mid's deps. + new_leaf = "export module Leaf;\nexport int leaf() { return 999; }\n" + (tmp_path / "leaf.cppm").write_text(new_leaf) + + leaf_path = tmp_path / "leaf.cppm" + client.text_document_did_save( + DidSaveTextDocumentParams( + text_document=TextDocumentIdentifier(uri=leaf_path.as_uri()) + ) + ) + + # Hover on Mid should trigger recompilation (Leaf PCM was invalidated). + event = client.wait_for_diagnostics(mid_uri) + await client.text_document_hover_async( + HoverParams(text_document=_doc(mid_uri), position=Position(line=0, character=0)) + ) + await asyncio.wait_for(event.wait(), timeout=60.0) + + diags = client.diagnostics.get(mid_uri, []) + assert len(diags) == 0, f"Expected clean compile after module update, got: {diags}"