use std::collections::{HashSet, VecDeque};
use std::path::PathBuf;
use std::time::Instant;
use super::super::concurrent::CodeGraph;
use super::super::edge::EdgeKind;
use super::super::file::FileId;
use super::super::memory::GraphMemorySize;
use super::super::mutation_target::GraphMutationTarget;
use super::super::node::{NodeId, NodeKind};
use super::super::rebuild::rebuild_graph::RebuildGraph;
use super::super::storage::{AuxiliaryIndices, NodeArena, NodeEntry};
use super::super::string::StringId;
use super::cancellation::CancellationToken;
use super::entrypoint::{BuildConfig, ParsedFileOutcome, parse_file};
use super::identity::IdentityIndex;
use super::parallel_commit::{
GlobalOffsets, phase2_assign_ranges, phase3_parallel_commit, phase4_apply_global_remap,
phase4c_prime_unify_cross_file_nodes, phase4d_bulk_insert_edges,
rebuild_indices as generic_rebuild_indices,
};
use super::pass3_intra::PendingEdge;
use super::pass4_cross::ExportMap;
use super::pass5_cross_language::{Pass5Stats, link_cross_language_edges_generic};
use super::phase4e_binding::BindingDerivationStats;
use super::phase4e_incremental::derive_binding_plane_incremental_generic;
use crate::graph::error::{GraphBuilderError, GraphResult};
use crate::plugin::PluginManager;
#[derive(Debug, Clone, Default)]
pub struct IncrementalStats {
pub nodes_removed: usize,
pub edges_removed: usize,
pub edges_added: usize,
pub identity_entries_removed: usize,
}
#[derive(Debug)]
pub struct FileRemovalResult {
pub stats: IncrementalStats,
pub removed_nodes: Vec<NodeId>,
}
pub fn remove_file_nodes(
file_id: FileId,
identity_index: &mut IdentityIndex,
arena: &mut NodeArena,
indices: &mut AuxiliaryIndices,
) -> FileRemovalResult {
let mut stats = IncrementalStats::default();
let removed_entries = identity_index.remove_file(file_id);
stats.identity_entries_removed = removed_entries.len();
let removed_nodes: Vec<NodeId> = removed_entries.iter().map(|(_, id)| *id).collect();
stats.nodes_removed = removed_nodes.len();
let node_metadata: Vec<_> = removed_nodes
.iter()
.filter_map(|&node_id| {
arena
.get(node_id)
.map(|entry| (node_id, entry.kind, entry.name, entry.qualified_name))
})
.collect();
indices.remove_file_with_info(file_id, node_metadata);
for &node_id in &removed_nodes {
let _ = arena.remove(node_id);
}
FileRemovalResult {
stats,
removed_nodes,
}
}
#[must_use]
pub fn add_edge_incremental(
source: NodeId,
target: NodeId,
kind: EdgeKind,
file: FileId,
) -> (IncrementalStats, PendingEdge) {
let stats = IncrementalStats {
edges_added: 1,
..Default::default()
};
let edge = PendingEdge {
source,
target,
kind,
file,
spans: vec![], };
(stats, edge)
}
#[must_use]
pub fn add_edges_incremental(edges: &[PendingEdge]) -> IncrementalStats {
IncrementalStats {
edges_added: edges.len(),
..Default::default()
}
}
pub fn remove_node(
node_id: NodeId,
identity_index: &mut IdentityIndex,
arena: &mut NodeArena,
) -> IncrementalStats {
let mut stats = IncrementalStats::default();
if identity_index.remove_node_id(node_id).is_some() {
stats.identity_entries_removed = 1;
}
if arena.remove(node_id).is_some() {
stats.nodes_removed = 1;
}
stats
}
pub fn remove_nodes_batch(
node_ids: &[NodeId],
identity_index: &mut IdentityIndex,
arena: &mut NodeArena,
) -> IncrementalStats {
let mut stats = IncrementalStats::default();
for &node_id in node_ids {
if identity_index.remove_node_id(node_id).is_some() {
stats.identity_entries_removed += 1;
}
if arena.remove(node_id).is_some() {
stats.nodes_removed += 1;
}
}
stats
}
#[derive(Debug, Clone)]
pub struct EdgeToRemove {
pub source: NodeId,
pub target: NodeId,
pub kind: EdgeKind,
pub file: FileId,
}
#[must_use]
pub fn compute_reverse_dep_closure(changed_files: &[FileId], graph: &CodeGraph) -> HashSet<FileId> {
let mut closure: HashSet<FileId> = changed_files.iter().copied().collect();
let mut frontier: VecDeque<FileId> = changed_files.iter().copied().collect();
while let Some(file_id) = frontier.pop_front() {
for dependent in graph.reverse_dependency_index(file_id) {
if closure.insert(dependent) {
frontier.push_back(dependent);
}
}
}
closure
}
pub fn incremental_rebuild(
current_graph: &CodeGraph,
changed_files: &[PathBuf],
closure: &HashSet<FileId>,
plugins: &PluginManager,
config: &BuildConfig,
cancellation: &CancellationToken,
) -> GraphResult<CodeGraph> {
let _ = config;
cancellation.check()?;
cancellation.check()?;
let mut rebuild_graph: RebuildGraph = current_graph.clone_for_rebuild();
cancellation.check()?;
let ordered_closure = ordered_closure_file_ids(closure);
for (iter_index, file_id) in ordered_closure.into_iter().enumerate() {
cancellation.check()?;
let _removed_nodes: Vec<NodeId> = rebuild_graph.remove_file(file_id);
#[cfg(any(test, feature = "rebuild-internals"))]
testing::fire_phase3b_iter_hook(iter_index, file_id, &rebuild_graph);
#[cfg(not(any(test, feature = "rebuild-internals")))]
let _ = iter_index;
}
cancellation.check()?;
#[cfg(any(test, feature = "rebuild-internals"))]
testing::fire_phase3b_post_substep3_hook(&rebuild_graph, closure);
let new_file_paths = phase3e_discover_new_file_paths(current_graph, changed_files);
let reparse_outcome = phase3c_reparse_closure(
current_graph,
closure,
&new_file_paths,
plugins,
cancellation,
)?;
#[cfg(any(test, feature = "rebuild-internals"))]
testing::fire_phase3c_post_reparse_hook(reparse_outcome.parsed.len());
cancellation.check()?;
let commit_output = phase3c_commit_reparsed(&mut rebuild_graph, reparse_outcome)?;
cancellation.check()?;
let Phase3cCommitOutput {
diagnostics: post_commit,
mut per_file_edges,
} = commit_output;
#[cfg(any(test, feature = "rebuild-internals"))]
testing::fire_phase3c_post_substep6_hook(&rebuild_graph, &post_commit);
#[cfg(not(any(test, feature = "rebuild-internals")))]
{
let _ = post_commit;
}
cancellation.check()?;
let export_map = phase3d_rebuild_export_map(&rebuild_graph);
#[cfg(any(test, feature = "rebuild-internals"))]
testing::fire_phase3d_post_export_map_hook(&rebuild_graph, &export_map);
cancellation.check()?;
let _ = &export_map;
let pass4d = phase3d_insert_cross_file_edges(&mut rebuild_graph, &mut per_file_edges);
#[cfg(any(test, feature = "rebuild-internals"))]
testing::fire_phase3d_post_pass4d_hook(&rebuild_graph, &pass4d);
#[cfg(not(any(test, feature = "rebuild-internals")))]
{
let _ = pass4d;
}
cancellation.check()?;
let binding_stats: BindingDerivationStats =
derive_binding_plane_incremental_generic(&mut rebuild_graph);
cancellation.check()?;
let pass5_stats: Pass5Stats = link_cross_language_edges_generic(&mut rebuild_graph);
#[cfg(any(test, feature = "rebuild-internals"))]
testing::fire_phase3d_post_pass5_hook(&rebuild_graph, &pass5_stats);
#[cfg(not(any(test, feature = "rebuild-internals")))]
{
let _ = pass5_stats;
}
cancellation.check()?;
let finalize_start = Instant::now();
let code_graph = rebuild_graph.finalize()?;
let finalize_elapsed = finalize_start.elapsed();
let heap_bytes = code_graph.heap_bytes();
log::info!(
target: "sqry_core::incremental_rebuild",
"Phase 3e publish: nodes={}, heap_bytes={}, \
finalize_elapsed_us={}, binding_scopes={}, binding_aliases={}, \
binding_shadows={}",
code_graph.node_count(),
heap_bytes,
finalize_elapsed.as_micros(),
binding_stats.scopes,
binding_stats.aliases,
binding_stats.shadows,
);
#[cfg(any(test, feature = "rebuild-internals"))]
testing::fire_phase3e_post_finalize_hook(&code_graph, heap_bytes, finalize_elapsed);
Ok(code_graph)
}
fn phase3e_discover_new_file_paths(
current_graph: &CodeGraph,
changed_files: &[PathBuf],
) -> Vec<PathBuf> {
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut new_paths: Vec<PathBuf> = Vec::new();
for path in changed_files {
if current_graph.files().get(path).is_some() {
continue;
}
if !seen.insert(path.clone()) {
continue;
}
new_paths.push(path.clone());
}
new_paths
}
struct ReparseOutcome {
parsed: Vec<(PathBuf, super::entrypoint::ParsedFile)>,
}
fn phase3c_reparse_closure(
current_graph: &CodeGraph,
closure: &HashSet<FileId>,
new_file_paths: &[PathBuf],
plugins: &PluginManager,
cancellation: &CancellationToken,
) -> GraphResult<ReparseOutcome> {
let mut closure_paths: Vec<(FileId, PathBuf)> = current_graph
.indexed_files()
.filter_map(|(fid, path)| {
if closure.contains(&fid) {
Some((fid, path.to_path_buf()))
} else {
None
}
})
.collect();
closure_paths.sort_by_key(|(fid, _)| fid.index());
let closure_len = closure_paths.len();
closure_paths.reserve(new_file_paths.len());
for path in new_file_paths {
closure_paths.push((FileId::new(u32::MAX), path.clone()));
}
debug_assert_eq!(
closure_paths.len(),
closure_len + new_file_paths.len(),
"Phase 3e new-file append must not collide with closure leg",
);
let mut parsed = Vec::with_capacity(closure_paths.len());
for (iter_index, (_fid, path)) in closure_paths.into_iter().enumerate() {
cancellation.check()?;
match parse_file(path.as_path(), plugins) {
Ok(ParsedFileOutcome::Parsed(pf)) => {
parsed.push((path, pf));
}
Ok(ParsedFileOutcome::Skipped) => {
}
Ok(ParsedFileOutcome::TimedOut { .. }) => {
}
Err(err) => {
log::debug!(
"Phase 3c: dropping closure file from commit plan due to \
re-parse failure: {err:#}"
);
}
}
#[cfg(any(test, feature = "rebuild-internals"))]
testing::fire_phase3c_iter_hook(iter_index);
#[cfg(not(any(test, feature = "rebuild-internals")))]
let _ = iter_index;
}
Ok(ReparseOutcome { parsed })
}
#[derive(Debug, Default, Clone)]
#[cfg_attr(not(any(test, feature = "rebuild-internals")), allow(dead_code))]
pub struct PostCommitDiagnostics {
pub files_committed: usize,
pub nodes_committed: usize,
pub strings_committed: usize,
pub edges_collected: usize,
}
struct Phase3cCommitOutput {
diagnostics: PostCommitDiagnostics,
per_file_edges: Vec<Vec<PendingEdge>>,
}
fn phase3c_commit_reparsed(
rebuild_graph: &mut RebuildGraph,
outcome: ReparseOutcome,
) -> GraphResult<Phase3cCommitOutput> {
if outcome.parsed.is_empty() {
return Ok(Phase3cCommitOutput {
diagnostics: PostCommitDiagnostics::default(),
per_file_edges: Vec::new(),
});
}
let parsed_count = outcome.parsed.len();
let mut file_info: Vec<(PathBuf, Option<crate::graph::Language>)> =
Vec::with_capacity(parsed_count);
let mut parsed_files: Vec<super::entrypoint::ParsedFile> = Vec::with_capacity(parsed_count);
for (path, pf) in outcome.parsed {
file_info.push((path, Some(pf.language)));
parsed_files.push(pf);
}
let file_ids = rebuild_graph
.files_mut()
.register_batch(&file_info)
.map_err(|err| GraphBuilderError::Internal {
reason: format!(
"Phase 3c sub-step 5 file registration failed on rebuild plane: {err:?}"
),
})?;
let node_offset = u32::try_from(rebuild_graph.nodes_mut().slot_count()).map_err(|_| {
GraphBuilderError::Internal {
reason: "Phase 3c sub-step 5: rebuild arena slot count exceeds u32::MAX".to_string(),
}
})?;
let string_offset = rebuild_graph.strings_mut().alloc_range(0).unwrap_or(1);
let offsets = GlobalOffsets {
node_offset,
string_offset,
};
let staging_refs: Vec<&super::StagingGraph> =
parsed_files.iter().map(|pf| &pf.staging).collect();
let plan = phase2_assign_ranges(&staging_refs, &file_ids, &offsets);
let placeholder = NodeEntry::new(NodeKind::Other, StringId::new(0), FileId::new(0));
rebuild_graph
.nodes_mut()
.alloc_range(plan.total_nodes, &placeholder)
.map_err(|err| GraphBuilderError::Internal {
reason: format!("Phase 3c sub-step 5: alloc_range on rebuild arena failed: {err:?}"),
})?;
rebuild_graph
.strings_mut()
.alloc_range(plan.total_strings)
.map_err(|err| GraphBuilderError::Internal {
reason: format!("Phase 3c sub-step 5: alloc_range on rebuild interner failed: {err}"),
})?;
let phase3 = phase3_parallel_commit(&plan, &staging_refs, rebuild_graph);
for fp in &plan.file_plans {
let start = fp.node_range.start;
let count = fp.node_range.end.saturating_sub(start);
rebuild_graph
.file_segments_mut()
.record_range(fp.file_id, start, count);
}
debug_assert_eq!(
phase3.per_file_node_ids.len(),
plan.file_plans.len(),
"Phase 3c sub-step 6: phase3 per-file node ID vector length must match plan length"
);
for (fp, node_ids) in plan.file_plans.iter().zip(phase3.per_file_node_ids.iter()) {
for nid in node_ids {
rebuild_graph.files_mut().record_node(fp.file_id, *nid);
}
}
let edges_collected = phase3.per_file_edges.iter().map(Vec::len).sum::<usize>();
Ok(Phase3cCommitOutput {
diagnostics: PostCommitDiagnostics {
files_committed: parsed_count,
nodes_committed: phase3.total_nodes_written,
strings_committed: phase3.total_strings_written,
edges_collected,
},
per_file_edges: phase3.per_file_edges,
})
}
#[derive(Debug, Default, Clone)]
#[cfg_attr(not(any(test, feature = "rebuild-internals")), allow(dead_code))]
pub struct Pass4dDiagnostics {
pub edges_submitted: usize,
pub dedup_remap_size: usize,
pub unification_candidate_pairs_examined: usize,
pub unification_nodes_merged: usize,
pub unification_edges_rewritten: usize,
pub final_edge_seq: u64,
}
fn phase3d_rebuild_export_map(rebuild_graph: &RebuildGraph) -> ExportMap {
let mut export_map = ExportMap::new();
let strings = GraphMutationTarget::strings(rebuild_graph);
for (node_id, entry) in GraphMutationTarget::nodes(rebuild_graph).iter() {
if !EXPORTABLE_KINDS.contains(&entry.kind) {
continue;
}
let Some(qn_id) = entry.qualified_name else {
continue;
};
let Some(qn_str) = strings.resolve(qn_id) else {
continue;
};
if qn_str.is_empty() {
continue;
}
export_map.register(qn_str.to_string(), entry.file, node_id);
}
export_map
}
const EXPORTABLE_KINDS: &[NodeKind] = &[
NodeKind::Function,
NodeKind::Method,
NodeKind::Macro,
NodeKind::Constant,
NodeKind::LambdaTarget,
NodeKind::Class,
NodeKind::Interface,
NodeKind::Trait,
NodeKind::Struct,
NodeKind::Enum,
NodeKind::EnumVariant,
NodeKind::EnumConstant,
NodeKind::Type,
NodeKind::TypeParameter,
NodeKind::Module,
NodeKind::JavaModule,
NodeKind::Variable,
NodeKind::Property,
NodeKind::Component,
NodeKind::Service,
NodeKind::Resource,
NodeKind::Endpoint,
NodeKind::Annotation,
];
fn phase3d_insert_cross_file_edges(
rebuild_graph: &mut RebuildGraph,
per_file_edges: &mut [Vec<PendingEdge>],
) -> Pass4dDiagnostics {
let edges_submitted: usize = per_file_edges.iter().map(Vec::len).sum();
let string_remap = rebuild_graph.strings_mut().build_dedup_table();
let dedup_remap_size = string_remap.len();
if !string_remap.is_empty() {
phase4_apply_global_remap(rebuild_graph.nodes_mut(), per_file_edges, &string_remap);
}
generic_rebuild_indices(rebuild_graph);
let unification = phase4c_prime_unify_cross_file_nodes(rebuild_graph, per_file_edges);
if unification.nodes_merged > 0 {
generic_rebuild_indices(rebuild_graph);
}
let final_edge_seq = phase4d_bulk_insert_edges(rebuild_graph, per_file_edges);
Pass4dDiagnostics {
edges_submitted,
dedup_remap_size,
unification_candidate_pairs_examined: unification.candidate_pairs_examined,
unification_nodes_merged: unification.nodes_merged,
unification_edges_rewritten: unification.edges_rewritten,
final_edge_seq,
}
}
fn ordered_closure_file_ids(closure: &HashSet<FileId>) -> Vec<FileId> {
let mut ordered: Vec<FileId> = closure.iter().copied().collect();
ordered.sort_by_key(|fid| fid.index());
ordered
}
#[cfg(any(test, feature = "rebuild-internals"))]
pub mod testing {
use super::{CodeGraph, ExportMap, FileId, HashSet, Pass5Stats, RebuildGraph};
use std::cell::RefCell;
type Phase3bPostSubstep3Hook = Box<dyn FnMut(&RebuildGraph, &HashSet<FileId>)>;
thread_local! {
static PHASE3B_POST_SUBSTEP3_HOOK: RefCell<Option<Phase3bPostSubstep3Hook>>
= const { RefCell::new(None) };
}
pub fn set_phase3b_post_substep3_hook<F>(hook: F) -> Option<Phase3bPostSubstep3Hook>
where
F: FnMut(&RebuildGraph, &HashSet<FileId>) + 'static,
{
PHASE3B_POST_SUBSTEP3_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
}
pub fn clear_phase3b_post_substep3_hook() {
PHASE3B_POST_SUBSTEP3_HOOK.with(|cell| {
let _ = cell.replace(None);
});
}
pub(super) fn fire_phase3b_post_substep3_hook(
rebuild_graph: &RebuildGraph,
closure: &HashSet<FileId>,
) {
PHASE3B_POST_SUBSTEP3_HOOK.with(|cell| {
if let Some(hook) = cell.borrow_mut().as_mut() {
hook(rebuild_graph, closure);
}
});
}
pub struct Phase3bHookGuard {
_sealed: (),
}
impl Phase3bHookGuard {
pub fn install<F>(hook: F) -> Self
where
F: FnMut(&RebuildGraph, &HashSet<FileId>) + 'static,
{
let _previous = set_phase3b_post_substep3_hook(hook);
Self { _sealed: () }
}
}
impl Drop for Phase3bHookGuard {
fn drop(&mut self) {
clear_phase3b_post_substep3_hook();
}
}
type Phase3bIterHook = Box<dyn FnMut(usize, FileId, &RebuildGraph)>;
thread_local! {
static PHASE3B_ITER_HOOK: RefCell<Option<Phase3bIterHook>>
= const { RefCell::new(None) };
}
pub fn set_phase3b_iter_hook<F>(hook: F) -> Option<Phase3bIterHook>
where
F: FnMut(usize, FileId, &RebuildGraph) + 'static,
{
PHASE3B_ITER_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
}
pub fn clear_phase3b_iter_hook() {
PHASE3B_ITER_HOOK.with(|cell| {
let _ = cell.replace(None);
});
}
pub(super) fn fire_phase3b_iter_hook(
iter_index: usize,
file_id: FileId,
rebuild_graph: &RebuildGraph,
) {
PHASE3B_ITER_HOOK.with(|cell| {
if let Some(hook) = cell.borrow_mut().as_mut() {
hook(iter_index, file_id, rebuild_graph);
}
});
}
pub struct Phase3bIterHookGuard {
_sealed: (),
}
impl Phase3bIterHookGuard {
pub fn install<F>(hook: F) -> Self
where
F: FnMut(usize, FileId, &RebuildGraph) + 'static,
{
let _previous = set_phase3b_iter_hook(hook);
Self { _sealed: () }
}
}
impl Drop for Phase3bIterHookGuard {
fn drop(&mut self) {
clear_phase3b_iter_hook();
}
}
type Phase3cIterHook = Box<dyn FnMut(usize)>;
thread_local! {
static PHASE3C_ITER_HOOK: RefCell<Option<Phase3cIterHook>>
= const { RefCell::new(None) };
}
pub fn set_phase3c_iter_hook<F>(hook: F) -> Option<Phase3cIterHook>
where
F: FnMut(usize) + 'static,
{
PHASE3C_ITER_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
}
pub fn clear_phase3c_iter_hook() {
PHASE3C_ITER_HOOK.with(|cell| {
let _ = cell.replace(None);
});
}
pub(super) fn fire_phase3c_iter_hook(iter_index: usize) {
PHASE3C_ITER_HOOK.with(|cell| {
if let Some(hook) = cell.borrow_mut().as_mut() {
hook(iter_index);
}
});
}
pub struct Phase3cIterHookGuard {
_sealed: (),
}
impl Phase3cIterHookGuard {
pub fn install<F>(hook: F) -> Self
where
F: FnMut(usize) + 'static,
{
let _previous = set_phase3c_iter_hook(hook);
Self { _sealed: () }
}
}
impl Drop for Phase3cIterHookGuard {
fn drop(&mut self) {
clear_phase3c_iter_hook();
}
}
type Phase3cReparseHook = Box<dyn FnMut(usize)>;
thread_local! {
static PHASE3C_POST_REPARSE_HOOK: RefCell<Option<Phase3cReparseHook>>
= const { RefCell::new(None) };
}
pub fn set_phase3c_post_reparse_hook<F>(hook: F) -> Option<Phase3cReparseHook>
where
F: FnMut(usize) + 'static,
{
PHASE3C_POST_REPARSE_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
}
pub fn clear_phase3c_post_reparse_hook() {
PHASE3C_POST_REPARSE_HOOK.with(|cell| {
let _ = cell.replace(None);
});
}
pub(super) fn fire_phase3c_post_reparse_hook(parsed_count: usize) {
PHASE3C_POST_REPARSE_HOOK.with(|cell| {
if let Some(hook) = cell.borrow_mut().as_mut() {
hook(parsed_count);
}
});
}
pub struct Phase3cReparseHookGuard {
_sealed: (),
}
impl Phase3cReparseHookGuard {
pub fn install<F>(hook: F) -> Self
where
F: FnMut(usize) + 'static,
{
let _previous = set_phase3c_post_reparse_hook(hook);
Self { _sealed: () }
}
}
impl Drop for Phase3cReparseHookGuard {
fn drop(&mut self) {
clear_phase3c_post_reparse_hook();
}
}
type Phase3cPostSubstep6Hook = Box<dyn FnMut(&RebuildGraph, &super::PostCommitDiagnostics)>;
thread_local! {
static PHASE3C_POST_SUBSTEP6_HOOK: RefCell<Option<Phase3cPostSubstep6Hook>>
= const { RefCell::new(None) };
}
pub fn set_phase3c_post_substep6_hook<F>(hook: F) -> Option<Phase3cPostSubstep6Hook>
where
F: FnMut(&RebuildGraph, &super::PostCommitDiagnostics) + 'static,
{
PHASE3C_POST_SUBSTEP6_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
}
pub fn clear_phase3c_post_substep6_hook() {
PHASE3C_POST_SUBSTEP6_HOOK.with(|cell| {
let _ = cell.replace(None);
});
}
pub(super) fn fire_phase3c_post_substep6_hook(
rebuild_graph: &RebuildGraph,
diagnostics: &super::PostCommitDiagnostics,
) {
PHASE3C_POST_SUBSTEP6_HOOK.with(|cell| {
if let Some(hook) = cell.borrow_mut().as_mut() {
hook(rebuild_graph, diagnostics);
}
});
}
pub struct Phase3cHookGuard {
_sealed: (),
}
impl Phase3cHookGuard {
pub fn install<F>(hook: F) -> Self
where
F: FnMut(&RebuildGraph, &super::PostCommitDiagnostics) + 'static,
{
let _previous = set_phase3c_post_substep6_hook(hook);
Self { _sealed: () }
}
}
impl Drop for Phase3cHookGuard {
fn drop(&mut self) {
clear_phase3c_post_substep6_hook();
}
}
const _: fn(&CodeGraph) = |_| {};
type Phase3dPostExportMapHook = Box<dyn FnMut(&RebuildGraph, &ExportMap)>;
thread_local! {
static PHASE3D_POST_EXPORT_MAP_HOOK: RefCell<Option<Phase3dPostExportMapHook>>
= const { RefCell::new(None) };
}
pub fn set_phase3d_post_export_map_hook<F>(hook: F) -> Option<Phase3dPostExportMapHook>
where
F: FnMut(&RebuildGraph, &ExportMap) + 'static,
{
PHASE3D_POST_EXPORT_MAP_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
}
pub fn clear_phase3d_post_export_map_hook() {
PHASE3D_POST_EXPORT_MAP_HOOK.with(|cell| {
let _ = cell.replace(None);
});
}
pub(super) fn fire_phase3d_post_export_map_hook(
rebuild_graph: &RebuildGraph,
export_map: &ExportMap,
) {
PHASE3D_POST_EXPORT_MAP_HOOK.with(|cell| {
if let Some(hook) = cell.borrow_mut().as_mut() {
hook(rebuild_graph, export_map);
}
});
}
pub struct Phase3dPostExportMapHookGuard {
_sealed: (),
}
impl Phase3dPostExportMapHookGuard {
pub fn install<F>(hook: F) -> Self
where
F: FnMut(&RebuildGraph, &ExportMap) + 'static,
{
let _previous = set_phase3d_post_export_map_hook(hook);
Self { _sealed: () }
}
}
impl Drop for Phase3dPostExportMapHookGuard {
fn drop(&mut self) {
clear_phase3d_post_export_map_hook();
}
}
type Phase3dPostPass4dHook = Box<dyn FnMut(&RebuildGraph, &super::Pass4dDiagnostics)>;
thread_local! {
static PHASE3D_POST_PASS4D_HOOK: RefCell<Option<Phase3dPostPass4dHook>>
= const { RefCell::new(None) };
}
pub fn set_phase3d_post_pass4d_hook<F>(hook: F) -> Option<Phase3dPostPass4dHook>
where
F: FnMut(&RebuildGraph, &super::Pass4dDiagnostics) + 'static,
{
PHASE3D_POST_PASS4D_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
}
pub fn clear_phase3d_post_pass4d_hook() {
PHASE3D_POST_PASS4D_HOOK.with(|cell| {
let _ = cell.replace(None);
});
}
pub(super) fn fire_phase3d_post_pass4d_hook(
rebuild_graph: &RebuildGraph,
diagnostics: &super::Pass4dDiagnostics,
) {
PHASE3D_POST_PASS4D_HOOK.with(|cell| {
if let Some(hook) = cell.borrow_mut().as_mut() {
hook(rebuild_graph, diagnostics);
}
});
}
pub struct Phase3dPostPass4dHookGuard {
_sealed: (),
}
impl Phase3dPostPass4dHookGuard {
pub fn install<F>(hook: F) -> Self
where
F: FnMut(&RebuildGraph, &super::Pass4dDiagnostics) + 'static,
{
let _previous = set_phase3d_post_pass4d_hook(hook);
Self { _sealed: () }
}
}
impl Drop for Phase3dPostPass4dHookGuard {
fn drop(&mut self) {
clear_phase3d_post_pass4d_hook();
}
}
type Phase3dPostPass5Hook = Box<dyn FnMut(&RebuildGraph, &Pass5Stats)>;
thread_local! {
static PHASE3D_POST_PASS5_HOOK: RefCell<Option<Phase3dPostPass5Hook>>
= const { RefCell::new(None) };
}
pub fn set_phase3d_post_pass5_hook<F>(hook: F) -> Option<Phase3dPostPass5Hook>
where
F: FnMut(&RebuildGraph, &Pass5Stats) + 'static,
{
PHASE3D_POST_PASS5_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
}
pub fn clear_phase3d_post_pass5_hook() {
PHASE3D_POST_PASS5_HOOK.with(|cell| {
let _ = cell.replace(None);
});
}
pub(super) fn fire_phase3d_post_pass5_hook(rebuild_graph: &RebuildGraph, stats: &Pass5Stats) {
PHASE3D_POST_PASS5_HOOK.with(|cell| {
if let Some(hook) = cell.borrow_mut().as_mut() {
hook(rebuild_graph, stats);
}
});
}
pub struct Phase3dPostPass5HookGuard {
_sealed: (),
}
impl Phase3dPostPass5HookGuard {
pub fn install<F>(hook: F) -> Self
where
F: FnMut(&RebuildGraph, &Pass5Stats) + 'static,
{
let _previous = set_phase3d_post_pass5_hook(hook);
Self { _sealed: () }
}
}
impl Drop for Phase3dPostPass5HookGuard {
fn drop(&mut self) {
clear_phase3d_post_pass5_hook();
}
}
use std::time::Duration;
type Phase3ePostFinalizeHook = Box<dyn FnMut(&CodeGraph, usize, Duration)>;
thread_local! {
static PHASE3E_POST_FINALIZE_HOOK: RefCell<Option<Phase3ePostFinalizeHook>>
= const { RefCell::new(None) };
}
pub fn set_phase3e_post_finalize_hook<F>(hook: F) -> Option<Phase3ePostFinalizeHook>
where
F: FnMut(&CodeGraph, usize, Duration) + 'static,
{
PHASE3E_POST_FINALIZE_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
}
pub fn clear_phase3e_post_finalize_hook() {
PHASE3E_POST_FINALIZE_HOOK.with(|cell| {
let _ = cell.replace(None);
});
}
pub(super) fn fire_phase3e_post_finalize_hook(
code_graph: &CodeGraph,
heap_bytes: usize,
finalize_elapsed: Duration,
) {
PHASE3E_POST_FINALIZE_HOOK.with(|cell| {
if let Some(hook) = cell.borrow_mut().as_mut() {
hook(code_graph, heap_bytes, finalize_elapsed);
}
});
}
pub struct Phase3ePostFinalizeHookGuard {
_sealed: (),
}
impl Phase3ePostFinalizeHookGuard {
pub fn install<F>(hook: F) -> Self
where
F: FnMut(&CodeGraph, usize, Duration) + 'static,
{
let _previous = set_phase3e_post_finalize_hook(hook);
Self { _sealed: () }
}
}
impl Drop for Phase3ePostFinalizeHookGuard {
fn drop(&mut self) {
clear_phase3e_post_finalize_hook();
}
}
}
#[cfg(test)]
mod tests {
use super::super::identity::IdentityKey;
use super::*;
use crate::graph::unified::StringId;
use crate::graph::unified::node::NodeKind;
use crate::graph::unified::storage::NodeEntry;
fn create_test_entry(name_id: StringId, file_id: FileId) -> NodeEntry {
NodeEntry::new(NodeKind::Function, name_id, file_id)
}
#[test]
fn test_remove_file_nodes() {
let mut arena = NodeArena::new();
let mut identity_index = IdentityIndex::new();
let mut indices = AuxiliaryIndices::new();
let file_id = FileId::new(5);
let name_id = StringId::new(1);
let entry1 = create_test_entry(name_id, file_id);
let entry2 = create_test_entry(name_id, file_id);
let node1 = arena.alloc(entry1).unwrap();
let node2 = arena.alloc(entry2).unwrap();
let key1 = IdentityKey::new(StringId::new(1), file_id, StringId::new(10));
let key2 = IdentityKey::new(StringId::new(1), file_id, StringId::new(11));
identity_index.insert(key1, node1);
identity_index.insert(key2, node2);
let result = remove_file_nodes(file_id, &mut identity_index, &mut arena, &mut indices);
assert_eq!(result.stats.nodes_removed, 2);
assert_eq!(result.stats.identity_entries_removed, 2);
assert_eq!(result.removed_nodes.len(), 2);
assert!(arena.get(node1).is_none());
assert!(arena.get(node2).is_none());
}
#[test]
fn test_add_edge_incremental() {
let source = NodeId::new(0, 1);
let target = NodeId::new(1, 1);
let file_id = FileId::new(0);
let (stats, edge) = add_edge_incremental(
source,
target,
EdgeKind::Calls {
argument_count: 0,
is_async: false,
},
file_id,
);
assert_eq!(stats.edges_added, 1);
assert_eq!(edge.source, source);
assert_eq!(edge.target, target);
assert!(matches!(
edge.kind,
EdgeKind::Calls {
argument_count: 0,
is_async: false
}
));
}
#[test]
fn test_add_edges_incremental_batch() {
let file_id = FileId::new(0);
let edges = vec![
PendingEdge {
source: NodeId::new(0, 1),
target: NodeId::new(1, 1),
kind: EdgeKind::Calls {
argument_count: 0,
is_async: false,
},
file: file_id,
spans: vec![],
},
PendingEdge {
source: NodeId::new(1, 1),
target: NodeId::new(2, 1),
kind: EdgeKind::References,
file: file_id,
spans: vec![],
},
PendingEdge {
source: NodeId::new(2, 1),
target: NodeId::new(0, 1),
kind: EdgeKind::Calls {
argument_count: 0,
is_async: false,
},
file: file_id,
spans: vec![],
},
];
let stats = add_edges_incremental(&edges);
assert_eq!(stats.edges_added, 3);
}
#[test]
fn test_remove_node() {
let mut arena = NodeArena::new();
let mut identity_index = IdentityIndex::new();
let file_id = FileId::new(0);
let name_id = StringId::new(1);
let entry = create_test_entry(name_id, file_id);
let node_id = arena.alloc(entry).unwrap();
let key = IdentityKey::new(StringId::new(1), file_id, StringId::new(10));
identity_index.insert(key, node_id);
let stats = remove_node(node_id, &mut identity_index, &mut arena);
assert_eq!(stats.nodes_removed, 1);
assert_eq!(stats.identity_entries_removed, 1);
assert!(arena.get(node_id).is_none());
}
#[test]
fn test_remove_nodes_batch() {
let mut arena = NodeArena::new();
let mut identity_index = IdentityIndex::new();
let file_id = FileId::new(0);
let name_id = StringId::new(1);
let node1 = arena.alloc(create_test_entry(name_id, file_id)).unwrap();
let node2 = arena.alloc(create_test_entry(name_id, file_id)).unwrap();
let node3 = arena.alloc(create_test_entry(name_id, file_id)).unwrap();
identity_index.insert(
IdentityKey::new(StringId::new(1), file_id, StringId::new(10)),
node1,
);
identity_index.insert(
IdentityKey::new(StringId::new(1), file_id, StringId::new(11)),
node2,
);
let stats = remove_nodes_batch(&[node1, node2, node3], &mut identity_index, &mut arena);
assert_eq!(stats.nodes_removed, 3);
assert_eq!(stats.identity_entries_removed, 2);
assert!(arena.get(node1).is_none());
assert!(arena.get(node2).is_none());
assert!(arena.get(node3).is_none());
}
#[test]
fn test_remove_nonexistent_node() {
let mut arena = NodeArena::new();
let mut identity_index = IdentityIndex::new();
let fake_id = NodeId::new(999, 1);
let stats = remove_node(fake_id, &mut identity_index, &mut arena);
assert_eq!(stats.nodes_removed, 0);
assert_eq!(stats.identity_entries_removed, 0);
}
#[test]
fn test_incremental_stats_default() {
let stats = IncrementalStats::default();
assert_eq!(stats.nodes_removed, 0);
assert_eq!(stats.edges_removed, 0);
assert_eq!(stats.edges_added, 0);
assert_eq!(stats.identity_entries_removed, 0);
}
fn build_closure_graph(files: &[&str]) -> (CodeGraph, Vec<FileId>, Vec<NodeId>) {
use crate::graph::unified::node::NodeKind;
use crate::graph::unified::storage::arena::NodeEntry;
use std::path::Path;
let mut graph = CodeGraph::new();
let placeholder = graph.strings_mut().intern("sym").unwrap();
let mut fids = Vec::with_capacity(files.len());
let mut nids = Vec::with_capacity(files.len());
for path in files {
let fid = graph.files_mut().register(Path::new(path)).unwrap();
let nid = graph
.nodes_mut()
.alloc(NodeEntry::new(NodeKind::Function, placeholder, fid))
.unwrap();
fids.push(fid);
nids.push(nid);
}
graph.rebuild_indices();
(graph, fids, nids)
}
fn add_import(
graph: &mut CodeGraph,
importer_node: NodeId,
exporter_node: NodeId,
importer_file: FileId,
) {
graph.edges_mut().add_edge(
importer_node,
exporter_node,
EdgeKind::Imports {
alias: None,
is_wildcard: false,
},
importer_file,
);
}
#[test]
fn closure_singleton_when_no_importers() {
let (graph, files, _) = build_closure_graph(&["lone.rs"]);
let closure = compute_reverse_dep_closure(&[files[0]], &graph);
assert_eq!(closure.len(), 1);
assert!(closure.contains(&files[0]));
}
#[test]
fn closure_transitive_a_imports_b_imports_c() {
let (mut graph, files, nodes) = build_closure_graph(&["a.rs", "b.rs", "c.rs"]);
let (a, b, c) = (files[0], files[1], files[2]);
let (na, nb, nc) = (nodes[0], nodes[1], nodes[2]);
add_import(&mut graph, na, nb, a); add_import(&mut graph, nb, nc, b);
let closure_c = compute_reverse_dep_closure(&[c], &graph);
assert_eq!(closure_c, [a, b, c].into_iter().collect::<HashSet<_>>());
let closure_b = compute_reverse_dep_closure(&[b], &graph);
assert_eq!(closure_b, [a, b].into_iter().collect::<HashSet<_>>());
let closure_a = compute_reverse_dep_closure(&[a], &graph);
assert_eq!(closure_a, [a].into_iter().collect::<HashSet<_>>());
}
#[test]
fn closure_diamond_shape_deduplicates() {
let (mut graph, files, nodes) = build_closure_graph(&["a.rs", "b.rs", "c.rs", "d.rs"]);
let (a, b, c, d) = (files[0], files[1], files[2], files[3]);
let (na, nb, nc, nd) = (nodes[0], nodes[1], nodes[2], nodes[3]);
add_import(&mut graph, na, nc, a);
add_import(&mut graph, nb, nc, b);
add_import(&mut graph, nd, na, d);
add_import(&mut graph, nd, nb, d);
let closure = compute_reverse_dep_closure(&[c], &graph);
assert_eq!(
closure,
[a, b, c, d].into_iter().collect::<HashSet<_>>(),
"diamond shape must close over all four files exactly once"
);
}
#[test]
fn closure_handles_cyclic_reverse_deps() {
let (mut graph, files, nodes) = build_closure_graph(&["a.rs", "b.rs"]);
let (a, b) = (files[0], files[1]);
let (na, nb) = (nodes[0], nodes[1]);
add_import(&mut graph, na, nb, a);
add_import(&mut graph, nb, na, b);
let closure_from_a = compute_reverse_dep_closure(&[a], &graph);
assert_eq!(closure_from_a, [a, b].into_iter().collect::<HashSet<_>>());
let closure_from_b = compute_reverse_dep_closure(&[b], &graph);
assert_eq!(closure_from_b, [a, b].into_iter().collect::<HashSet<_>>());
}
#[test]
fn closure_multiple_starting_files_all_included() {
let (mut graph, files, nodes) = build_closure_graph(&["p.rs", "q.rs", "x.rs", "y.rs"]);
let (p, q, x, y) = (files[0], files[1], files[2], files[3]);
let (np, nq, nx, ny) = (nodes[0], nodes[1], nodes[2], nodes[3]);
add_import(&mut graph, np, nq, p);
add_import(&mut graph, nx, ny, x);
let closure = compute_reverse_dep_closure(&[q, y], &graph);
assert_eq!(closure, [p, q, x, y].into_iter().collect::<HashSet<_>>());
}
#[test]
fn closure_empty_input_returns_empty() {
let (graph, _, _) = build_closure_graph(&["a.rs"]);
let closure = compute_reverse_dep_closure(&[], &graph);
assert!(closure.is_empty());
}
#[test]
fn closure_unregistered_files_passed_through() {
let (graph, _, _) = build_closure_graph(&["a.rs"]);
let bogus = FileId::new(9999);
let closure = compute_reverse_dep_closure(&[bogus], &graph);
assert_eq!(closure, [bogus].into_iter().collect::<HashSet<_>>());
}
fn add_call(
graph: &mut CodeGraph,
caller_node: NodeId,
callee_node: NodeId,
caller_file: FileId,
) {
graph.edges_mut().add_edge(
caller_node,
callee_node,
EdgeKind::Calls {
argument_count: 0,
is_async: false,
},
caller_file,
);
}
fn add_reference(
graph: &mut CodeGraph,
referrer_node: NodeId,
referenced_node: NodeId,
referrer_file: FileId,
) {
graph.edges_mut().add_edge(
referrer_node,
referenced_node,
EdgeKind::References,
referrer_file,
);
}
fn add_http_request(
graph: &mut CodeGraph,
client_node: NodeId,
endpoint_node: NodeId,
client_file: FileId,
) {
let url = graph.strings_mut().intern("/api/harness").unwrap();
graph.edges_mut().add_edge(
client_node,
endpoint_node,
EdgeKind::HttpRequest {
method: crate::graph::unified::edge::HttpMethod::Get,
url: Some(url),
},
client_file,
);
}
#[test]
fn closure_includes_cross_file_callers() {
let (mut graph, files, nodes) = build_closure_graph(&["a.rs", "b.rs"]);
let (a, b) = (files[0], files[1]);
let (na, nb) = (nodes[0], nodes[1]);
add_call(&mut graph, na, nb, a);
let closure = compute_reverse_dep_closure(&[b], &graph);
assert_eq!(
closure,
[a, b].into_iter().collect::<HashSet<_>>(),
"cross-file Calls edge must drive closure widening"
);
let closure_a = compute_reverse_dep_closure(&[a], &graph);
assert_eq!(closure_a, [a].into_iter().collect::<HashSet<_>>());
}
#[test]
fn closure_includes_cross_file_references() {
let (mut graph, files, nodes) = build_closure_graph(&["a.rs", "b.rs"]);
let (a, b) = (files[0], files[1]);
let (na, nb) = (nodes[0], nodes[1]);
add_reference(&mut graph, na, nb, a);
let closure = compute_reverse_dep_closure(&[b], &graph);
assert_eq!(
closure,
[a, b].into_iter().collect::<HashSet<_>>(),
"cross-file References edge must drive closure widening"
);
}
#[test]
fn closure_includes_cross_language_http_dependents() {
let (mut graph, files, nodes) = build_closure_graph(&["client.ts", "server.ts"]);
let (client, server) = (files[0], files[1]);
let (nclient, nserver) = (nodes[0], nodes[1]);
add_http_request(&mut graph, nclient, nserver, client);
let closure = compute_reverse_dep_closure(&[server], &graph);
assert_eq!(
closure,
[client, server].into_iter().collect::<HashSet<_>>(),
"cross-language HttpRequest edge must drive closure widening"
);
}
#[test]
fn incremental_rebuild_empty_inputs_return_empty_graph() {
let graph = CodeGraph::new();
let plugins = crate::plugin::PluginManager::new();
let config = super::super::entrypoint::BuildConfig::default();
let closure: HashSet<FileId> = HashSet::new();
let cancellation = CancellationToken::new();
let result = incremental_rebuild(&graph, &[], &closure, &plugins, &config, &cancellation)
.expect("empty rebuild is a no-op; must succeed without error");
assert_eq!(
result.node_count(),
0,
"empty rebuild must return a graph with zero nodes"
);
}
#[test]
fn incremental_rebuild_returns_cancelled_if_preflight_check_fails() {
let graph = CodeGraph::new();
let plugins = crate::plugin::PluginManager::new();
let config = super::super::entrypoint::BuildConfig::default();
let closure: HashSet<FileId> = HashSet::new();
let cancellation = CancellationToken::new();
cancellation.cancel();
let err = incremental_rebuild(&graph, &[], &closure, &plugins, &config, &cancellation)
.expect_err("cancelled token must short-circuit incremental_rebuild");
assert!(
matches!(err, GraphBuilderError::Cancelled),
"expected GraphBuilderError::Cancelled (not Internal), got: {err:?}"
);
}
#[test]
fn incremental_rebuild_delegates_to_full_build_when_not_cancelled() {
use std::fs;
let temp = tempfile::tempdir().expect("create tempdir");
let src_dir = temp.path().join("src");
fs::create_dir_all(&src_dir).expect("create src dir");
fs::write(src_dir.join("lib.rs"), b"pub fn noop() {}\n").expect("write src file");
let current_graph = CodeGraph::new();
let plugins = crate::plugin::PluginManager::new();
let config = super::super::entrypoint::BuildConfig::default();
let closure: HashSet<FileId> = HashSet::new();
let cancellation = CancellationToken::new();
let changed = vec![src_dir.join("lib.rs")];
let result = incremental_rebuild(
¤t_graph,
&changed,
&closure,
&plugins,
&config,
&cancellation,
);
match result {
Ok(_) => {
}
Err(GraphBuilderError::Internal { reason }) => {
assert!(
reason.contains("No graph builders registered")
|| reason.contains("Gate 0a stub")
|| reason.contains("Phase 3b fallback")
|| reason.contains("Phase 3c fallback")
|| reason.contains("Phase 3d fallback"),
"delegation reached full-build but failed for an unexpected reason: {reason}"
);
}
Err(GraphBuilderError::Cancelled) => {
panic!(
"Phase 3a pre-flight must NOT fire on a live (un-cancelled) token, but \
incremental_rebuild returned GraphBuilderError::Cancelled"
);
}
Err(other) => {
panic!("unexpected error shape from Phase 3a stub delegation: {other:?}",)
}
}
assert!(!cancellation.is_cancelled());
}
use std::cell::RefCell;
use std::rc::Rc;
fn build_closure_graph_with_buckets(files: &[&str]) -> (CodeGraph, Vec<FileId>, Vec<NodeId>) {
let (mut graph, fids, nids) = build_closure_graph(files);
for (&fid, &nid) in fids.iter().zip(nids.iter()) {
graph.files_mut().record_node(fid, nid);
}
(graph, fids, nids)
}
fn build_chain_graph() -> (CodeGraph, FileId, FileId, FileId) {
let (mut graph, files, nodes) = build_closure_graph_with_buckets(&["a.rs", "b.rs", "c.rs"]);
let (a, b, c) = (files[0], files[1], files[2]);
let (na, nb, nc) = (nodes[0], nodes[1], nodes[2]);
add_import(&mut graph, na, nb, a); add_import(&mut graph, nb, nc, b); (graph, a, b, c)
}
#[test]
fn incremental_rebuild_phase3b_constructs_rebuild_graph_and_removes_closure_members() {
let (graph, _a, _b, c) = build_chain_graph();
let plugins = crate::plugin::PluginManager::new();
let config = super::super::entrypoint::BuildConfig::default();
let cancellation = CancellationToken::new();
let closure = compute_reverse_dep_closure(&[c], &graph);
assert_eq!(closure.len(), 3, "chain graph must widen over {{a, b, c}}");
#[derive(Default)]
struct Observations {
hook_fired: u32,
pending_tombstones_after_substep3: usize,
closure_size_seen_by_hook: usize,
}
let obs = Rc::new(RefCell::new(Observations::default()));
let obs_hook = Rc::clone(&obs);
let _guard = testing::Phase3bHookGuard::install(move |rebuild_graph, hook_closure| {
let mut o = obs_hook.borrow_mut();
o.hook_fired += 1;
o.pending_tombstones_after_substep3 = rebuild_graph.pending_tombstone_count();
o.closure_size_seen_by_hook = hook_closure.len();
});
let _ = incremental_rebuild(&graph, &[], &closure, &plugins, &config, &cancellation);
let o = obs.borrow();
assert_eq!(
o.hook_fired, 1,
"sub-step 3 hook must fire exactly once per incremental_rebuild call"
);
assert_eq!(
o.closure_size_seen_by_hook, 3,
"hook must receive the same closure that was passed in"
);
assert_eq!(
o.pending_tombstones_after_substep3, 3,
"sub-step 3 must call rebuild_graph.remove_file on every closure file; \
3 closure files × 1 node each = 3 staged tombstones"
);
}
#[test]
fn incremental_rebuild_phase3b_hook_sees_fresh_rebuild_graph_on_empty_closure() {
let (graph, files, _nodes) = build_closure_graph_with_buckets(&["lone.rs"]);
let plugins = crate::plugin::PluginManager::new();
let config = super::super::entrypoint::BuildConfig::default();
let cancellation = CancellationToken::new();
let closure: HashSet<FileId> = HashSet::new();
let hook_fired = Rc::new(RefCell::new(0u32));
let pending_after = Rc::new(RefCell::new(usize::MAX));
let hook_fired_clone = Rc::clone(&hook_fired);
let pending_after_clone = Rc::clone(&pending_after);
let _guard = testing::Phase3bHookGuard::install(move |rebuild_graph, _| {
*hook_fired_clone.borrow_mut() += 1;
*pending_after_clone.borrow_mut() = rebuild_graph.pending_tombstone_count();
});
let _ = incremental_rebuild(
&graph,
&[std::path::PathBuf::from("lone.rs")],
&closure,
&plugins,
&config,
&cancellation,
);
assert_eq!(
*hook_fired.borrow(),
1,
"hook must fire even when the closure is empty (sub-step 2 + trivial sub-step 3)"
);
assert_eq!(
*pending_after.borrow(),
0,
"empty closure must leave `rebuild_graph` with zero staged tombstones"
);
let indexed: Vec<_> = graph.indexed_files().collect();
assert!(
indexed.iter().any(|(fid, _)| *fid == files[0]),
"committed graph must be untouched by Phase 3b (only the cloned RebuildGraph is mutated)"
);
}
#[test]
fn incremental_rebuild_phase3b_cancels_mid_closure_without_full_completion() {
let (graph, _a, _b, c) = build_chain_graph();
let plugins = crate::plugin::PluginManager::new();
let config = super::super::entrypoint::BuildConfig::default();
let closure = compute_reverse_dep_closure(&[c], &graph);
let cancellation = CancellationToken::new();
assert!(
!cancellation.is_cancelled(),
"sanity check: token must start un-cancelled before we cancel it for the pre-flight path"
);
cancellation.cancel();
assert!(
cancellation.is_cancelled(),
"pre-flight precondition: token must be cancelled before incremental_rebuild is invoked"
);
let post_fired = Rc::new(RefCell::new(0u32));
let post_fired_hook = Rc::clone(&post_fired);
let _post_guard = testing::Phase3bHookGuard::install(move |_, _| {
*post_fired_hook.borrow_mut() += 1;
});
let iter_events = Rc::new(RefCell::new(Vec::<(usize, FileId)>::new()));
let iter_events_hook = Rc::clone(&iter_events);
let _iter_guard = testing::Phase3bIterHookGuard::install(move |idx, fid, _rg| {
iter_events_hook.borrow_mut().push((idx, fid));
});
let result = incremental_rebuild(&graph, &[], &closure, &plugins, &config, &cancellation);
let err =
result.expect_err("pre-flight cancellation must short-circuit incremental_rebuild");
assert!(
matches!(err, GraphBuilderError::Cancelled),
"expected GraphBuilderError::Cancelled, got: {err:?}"
);
assert_eq!(
*post_fired.borrow(),
0,
"pre-flight cancellation must NOT let execution reach the post-substep3 hook"
);
assert_eq!(
iter_events.borrow().len(),
0,
"pre-flight cancellation must prevent the Step 3 loop from running any iteration; \
iter-hook fire count would be >0 if the loop ran even once"
);
}
#[test]
fn incremental_rebuild_phase3b_iteration_cancellation_between_remove_calls() {
let (graph, _a, _b, c) = build_chain_graph();
let plugins = crate::plugin::PluginManager::new();
let config = super::super::entrypoint::BuildConfig::default();
let closure = compute_reverse_dep_closure(&[c], &graph);
assert_eq!(
closure.len(),
3,
"chain graph's reverse closure over `c` must contain {{a, b, c}} for this test to \
actually exercise multiple iterations"
);
let cancellation = CancellationToken::new();
assert!(
!cancellation.is_cancelled(),
"Step 3 loop precondition: token MUST start un-cancelled; this is the sole invariant \
that separates this test from the pre-flight test"
);
let post_fired = Rc::new(RefCell::new(0u32));
let post_fired_hook = Rc::clone(&post_fired);
let _post_guard = testing::Phase3bHookGuard::install(move |_, _| {
*post_fired_hook.borrow_mut() += 1;
});
let iter_events = Rc::new(RefCell::new(Vec::<(usize, FileId)>::new()));
let iter_events_hook = Rc::clone(&iter_events);
let cancel_from_hook = cancellation.clone();
let _iter_guard = testing::Phase3bIterHookGuard::install(move |idx, fid, _rg| {
iter_events_hook.borrow_mut().push((idx, fid));
if idx == 0 {
cancel_from_hook.cancel();
}
});
let result = incremental_rebuild(&graph, &[], &closure, &plugins, &config, &cancellation);
let err = result.expect_err(
"token cancelled mid-loop must short-circuit at the loop-top cancellation.check()",
);
assert!(
matches!(err, GraphBuilderError::Cancelled),
"expected GraphBuilderError::Cancelled from the Step 3 loop check, got: {err:?}"
);
let events = iter_events.borrow();
assert_eq!(
events.len(),
1,
"Step 3 loop-top `cancellation.check()` must short-circuit between iterations 0 and \
1; iter-hook observed {:?} instead of exactly one (idx=0) event",
events
);
assert_eq!(
events[0].0, 0,
"first and only iter-hook fire must be for iteration 0 (the iteration that flipped \
cancellation)"
);
assert_eq!(
*post_fired.borrow(),
0,
"Step 3 loop cancellation must NOT let execution reach the post-substep3 hook"
);
}
#[test]
fn incremental_rebuild_phase3b_still_delegates_to_full_build_fallback() {
let (graph, _a, _b, c) = build_chain_graph();
let plugins = crate::plugin::PluginManager::new();
let config = super::super::entrypoint::BuildConfig::default();
let cancellation = CancellationToken::new();
let closure = compute_reverse_dep_closure(&[c], &graph);
let hook_fired = Rc::new(RefCell::new(false));
let hook_fired_clone = Rc::clone(&hook_fired);
let _guard = testing::Phase3bHookGuard::install(move |_, _| {
*hook_fired_clone.borrow_mut() = true;
});
let result = incremental_rebuild(&graph, &[], &closure, &plugins, &config, &cancellation);
assert!(
*hook_fired.borrow(),
"Phase 3b sub-step 3 hook must fire before the fallback delegates to \
build_unified_graph — otherwise the scaffolding is bypassed"
);
match result {
Ok(_) => {
}
Err(GraphBuilderError::Internal { reason }) => {
assert!(
reason.contains("No graph builders registered")
|| reason.contains("Phase 3b fallback")
|| reason.contains("Phase 3c fallback")
|| reason.contains("Phase 3d fallback"),
"unexpected Internal reason from Phase 3b fallback: {reason}"
);
}
Err(GraphBuilderError::Cancelled) => {
panic!(
"live (un-cancelled) token must NOT produce Cancelled from Phase 3b — \
scaffolding must not leak cancellation"
);
}
Err(other) => panic!("unexpected error shape from Phase 3b fallback: {other:?}"),
}
}
#[test]
fn incremental_rebuild_phase3b_preserves_current_graph_reference() {
let (graph, files, _nodes) = build_closure_graph_with_buckets(&["x.rs", "y.rs", "z.rs"]);
let plugins = crate::plugin::PluginManager::new();
let config = super::super::entrypoint::BuildConfig::default();
let cancellation = CancellationToken::new();
let closure: HashSet<FileId> = files.iter().copied().collect();
let before: HashSet<FileId> = graph.indexed_files().map(|(fid, _)| fid).collect();
let _ = incremental_rebuild(&graph, &[], &closure, &plugins, &config, &cancellation);
let after: HashSet<FileId> = graph.indexed_files().map(|(fid, _)| fid).collect();
assert_eq!(
before, after,
"sub-step 2's clone_for_rebuild must deep-clone; `current_graph` must be \
untouched by any Phase 3b closure removals"
);
}
#[test]
fn ordered_closure_file_ids_is_deterministic_and_sorted_by_index() {
let mut closure: HashSet<FileId> = HashSet::new();
closure.insert(FileId::new(5));
closure.insert(FileId::new(1));
closure.insert(FileId::new(9));
closure.insert(FileId::new(3));
let ordered = super::ordered_closure_file_ids(&closure);
assert_eq!(
ordered,
vec![
FileId::new(1),
FileId::new(3),
FileId::new(5),
FileId::new(9),
]
);
let ordered_again = super::ordered_closure_file_ids(&closure);
assert_eq!(ordered, ordered_again);
}
#[test]
fn phase3b_hook_guard_clears_hook_on_drop() {
let fire_count = Rc::new(RefCell::new(0u32));
let fire_count_hook = Rc::clone(&fire_count);
{
let _guard = testing::Phase3bHookGuard::install(move |_, _| {
*fire_count_hook.borrow_mut() += 1;
});
}
let (graph, _a, _b, c) = build_chain_graph();
let plugins = crate::plugin::PluginManager::new();
let config = super::super::entrypoint::BuildConfig::default();
let cancellation = CancellationToken::new();
let closure = compute_reverse_dep_closure(&[c], &graph);
let _ = incremental_rebuild(&graph, &[], &closure, &plugins, &config, &cancellation);
assert_eq!(
*fire_count.borrow(),
0,
"dropped Phase3bHookGuard must leave no installed hook"
);
}
}