Skip to main content

hearth_graph/graph/
mod.rs

1//! Incremental module graph storage and deterministic dependency queries.
2//!
3//! Import-extraction support is supplied explicitly to [`ModuleGraph::upsert_file`].
4//! Callers should pass [`LanguageRegistry::supports_imports`][crate::LanguageRegistry::supports_imports]
5//! for the analyzed path. Resolver liveness is sampled from [`ResolverSet`] at
6//! resolution time and stored with the node, so queries need only `&self`.
7
8use std::collections::VecDeque;
9
10use compact_str::CompactString;
11use rustc_hash::{FxHashMap, FxHashSet};
12
13use crate::{
14    FileAnalysis, ImportKind, RawImport, ResolutionCompleteness, ResolutionOutcome, Resolved,
15    ResolverSet, UnresolvedReason,
16};
17
18/// The result of inserting or refreshing one analyzed file.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum UpsertOutcome {
21    /// The path did not previously exist in the graph.
22    Inserted,
23    /// An analyzed node changed or a stub was promoted.
24    Updated,
25    /// The content and resolver generation were already current.
26    Unchanged,
27}
28
29/// Accuracy attached to a dependency query.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Guarantee {
32    /// The structural completeness conditions for the query are satisfied.
33    Exact,
34    /// The graph may omit dependencies.
35    Approximate,
36}
37
38impl Guarantee {
39    fn weakest(self, other: Self) -> Self {
40        if self == Self::Exact && other == Self::Exact {
41            Self::Exact
42        } else {
43            Self::Approximate
44        }
45    }
46}
47
48/// State of one module-graph node.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum NodeState {
51    /// Source for this node was analyzed.
52    Analyzed {
53        /// Caller-supplied content hash.
54        content_hash: u64,
55        /// Whether non-literal imports prevented complete extraction.
56        has_opaque_imports: bool,
57        /// Registered language name, when recognized.
58        language: Option<CompactString>,
59    },
60    /// A resolved path that has not been analyzed yet.
61    Stub,
62}
63
64/// Target stored on an import edge.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum EdgeTarget {
67    /// Slot of another module node.
68    Node(u32),
69    /// Package or other dependency outside the module graph.
70    External(CompactString),
71    /// Import that could not be resolved.
72    Unresolved(UnresolvedReason),
73}
74
75/// Owned target returned by graph queries.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum EdgeTargetOwned {
78    /// Path of another module node.
79    Path(CompactString),
80    /// Package or other dependency outside the module graph.
81    External(CompactString),
82    /// Import that could not be resolved.
83    Unresolved(UnresolvedReason),
84}
85
86/// One resolved import retained by the module graph.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ImportEdge {
89    /// Import syntax and source location exactly as extracted.
90    pub raw: RawImport,
91    /// Resolution target.
92    pub target: EdgeTarget,
93}
94
95/// One occupied module-graph slot.
96#[derive(Debug, Clone)]
97pub struct ModuleNode {
98    /// Canonical path used as the graph key.
99    pub path: CompactString,
100    /// Whether this path is analyzed or only a resolution stub.
101    pub state: NodeState,
102    /// Outgoing imports in extraction order.
103    pub out: Vec<ImportEdge>,
104    /// Slots with at least one edge targeting this node.
105    pub(crate) rdeps: FxHashSet<u32>,
106    /// Deduplicated resolver configuration paths consulted by outgoing edges.
107    pub config_dependencies: Vec<CompactString>,
108    imports_supported: bool,
109    resolver_live: bool,
110    resolution_complete: bool,
111    resolved_at: u64,
112}
113
114impl ModuleNode {
115    fn stub(path: CompactString) -> Self {
116        Self {
117            path,
118            state: NodeState::Stub,
119            out: Vec::new(),
120            rdeps: FxHashSet::default(),
121            config_dependencies: Vec::new(),
122            imports_supported: false,
123            resolver_live: false,
124            resolution_complete: false,
125            resolved_at: 0,
126        }
127    }
128
129    /// Resolver generation at which this analyzed node's edges were produced.
130    #[must_use]
131    pub fn resolved_generation(&self) -> Option<u64> {
132        matches!(self.state, NodeState::Analyzed { .. }).then_some(self.resolved_at)
133    }
134
135    /// Whether import extraction was available when this node was upserted.
136    #[must_use]
137    pub fn imports_supported(&self) -> bool {
138        self.imports_supported
139    }
140
141    /// Whether the matching resolver was live when this node was resolved.
142    #[must_use]
143    pub fn resolver_live(&self) -> bool {
144        self.resolver_live
145    }
146
147    /// Whether every outgoing edge was produced by a complete resolution.
148    #[must_use]
149    pub fn resolution_complete(&self) -> bool {
150        self.resolution_complete
151    }
152}
153
154/// One dependency edge materialized with paths instead of graph slots.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct DepEdge {
157    /// Importing file.
158    pub from: CompactString,
159    /// Owned resolution target.
160    pub to: EdgeTargetOwned,
161    /// Specifier exactly as written.
162    pub specifier: CompactString,
163    /// Syntactic import kind.
164    pub kind: ImportKind,
165    /// 1-based source line.
166    pub line: u32,
167    /// Byte range of the specifier.
168    pub span: (u32, u32),
169}
170
171/// Files used to produce a query answer.
172#[derive(Debug, Clone, Default, PartialEq, Eq)]
173pub struct Coverage {
174    /// Number of traversed analyzed nodes.
175    pub analyzed: u64,
176    /// Number of traversed stub nodes.
177    pub stubs: u64,
178    /// Number of traversed analyzed nodes with opaque imports.
179    pub opaque_files: u64,
180    /// Traversed analyzed path/hash pairs, sorted by path.
181    pub basis: Vec<(CompactString, u64)>,
182}
183
184/// Result of a forward- or reverse-dependency query.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct DepsResult {
187    /// Matching dependency edges.
188    pub edges: Vec<DepEdge>,
189    /// Structural accuracy of this answer.
190    pub guarantee: Guarantee,
191    /// Nodes traversed to produce this answer.
192    pub coverage: Coverage,
193}
194
195/// Result of a bidirectional breadth-first traversal.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct NeighborhoodResult {
198    /// Reached module paths, sorted lexicographically.
199    pub nodes: Vec<CompactString>,
200    /// Node-to-node edges induced by the reached paths.
201    pub edges: Vec<DepEdge>,
202    /// Weakest guarantee used by the traversal.
203    pub guarantee: Guarantee,
204    /// Reached nodes and their analyzed hashes.
205    pub coverage: Coverage,
206}
207
208/// Slotted, incrementally maintained repository module graph.
209#[derive(Debug, Default)]
210pub struct ModuleGraph {
211    nodes: Vec<Option<ModuleNode>>,
212    by_path: FxHashMap<CompactString, u32>,
213    free: Vec<u32>,
214    inexact_nodes: usize,
215    generation: u64,
216    universe_complete: bool,
217    resolver_generation: u64,
218}
219
220impl ModuleGraph {
221    /// Creates an empty graph.
222    #[must_use]
223    pub fn new() -> Self {
224        Self::default()
225    }
226
227    /// Records whether the caller has supplied a complete source universe.
228    pub fn set_universe_complete(&mut self, complete: bool) {
229        if self.universe_complete != complete {
230            self.universe_complete = complete;
231            self.record_mutation();
232        }
233    }
234
235    /// Returns the graph mutation generation.
236    #[must_use]
237    pub fn generation(&self) -> u64 {
238        self.generation
239    }
240
241    /// Returns the resolver configuration generation.
242    #[must_use]
243    pub fn resolver_generation(&self) -> u64 {
244        self.resolver_generation
245    }
246
247    /// Returns whether an analyzed node has the supplied content hash.
248    #[must_use]
249    pub fn contains(&self, path: &str, hash: u64) -> bool {
250        self.node(path).is_some_and(|node| {
251            matches!(
252                node.state,
253                NodeState::Analyzed {
254                    content_hash,
255                    ..
256                } if content_hash == hash
257            )
258        })
259    }
260
261    /// Returns an occupied node by path.
262    #[must_use]
263    pub fn node(&self, path: &str) -> Option<&ModuleNode> {
264        let slot = *self.by_path.get(path)?;
265        self.nodes.get(slot as usize)?.as_ref()
266    }
267
268    /// Sorted importer paths of `path` — a slot-history-independent view of
269    /// the reverse-dependency set.
270    pub fn rdeps_paths(&self, path: &str) -> Option<Vec<CompactString>> {
271        let slot = *self.by_path.get(path)?;
272        let node = self.occupied(slot);
273        let mut paths: Vec<CompactString> = node
274            .rdeps
275            .iter()
276            .map(|&importer| self.occupied(importer).path.clone())
277            .collect();
278        paths.sort_unstable();
279        Some(paths)
280    }
281
282    /// Inserts or refreshes one file analysis.
283    ///
284    /// `imports_supported` should be computed from the same language registry
285    /// that produced `analysis`.
286    pub fn upsert_file(
287        &mut self,
288        analysis: &FileAnalysis,
289        resolvers: &ResolverSet,
290        imports_supported: bool,
291    ) -> UpsertOutcome {
292        let existing = self.by_path.get(analysis.path.as_str()).copied();
293        if existing.is_some_and(|slot| {
294            let node = self.occupied(slot);
295            matches!(
296                node.state,
297                NodeState::Analyzed {
298                    content_hash,
299                    ..
300                } if content_hash == analysis.content_hash
301            ) && node.resolved_at == self.resolver_generation
302        }) {
303            return UpsertOutcome::Unchanged;
304        }
305
306        let resolutions = resolve_imports(resolvers, &analysis.path, &analysis.imports);
307        let resolver_live =
308            resolver_is_live(analysis.language.as_deref(), &analysis.imports, resolvers);
309        let outcome = if existing.is_some() {
310            UpsertOutcome::Updated
311        } else {
312            UpsertOutcome::Inserted
313        };
314        let slot =
315            existing.unwrap_or_else(|| self.allocate(ModuleNode::stub(analysis.path.clone())));
316        let was_exact = self.node_is_rdeps_exact(slot);
317        let old_targets = self.node_targets(slot);
318        let baseline_completeness = analysis
319            .language
320            .as_deref()
321            .map_or(ResolutionCompleteness::Complete, |language| {
322                resolvers.baseline_completeness(language)
323            });
324        let (edges, config_dependencies, resolution_complete) =
325            self.materialize_resolutions(resolutions, baseline_completeness);
326        let new_targets = targets_from_edges(&edges);
327
328        self.update_rdeps(slot, &old_targets, &new_targets);
329        let resolved_at = self.resolver_generation;
330        let node = self.occupied_mut(slot);
331        node.state = NodeState::Analyzed {
332            content_hash: analysis.content_hash,
333            has_opaque_imports: analysis.has_opaque_imports,
334            language: analysis.language.clone(),
335        };
336        node.out = edges;
337        node.config_dependencies = config_dependencies;
338        node.imports_supported = imports_supported;
339        node.resolver_live = resolver_live;
340        node.resolution_complete = resolution_complete;
341        node.resolved_at = resolved_at;
342        let is_exact = self.node_is_rdeps_exact(slot);
343        self.record_exactness_transition(was_exact, is_exact);
344        self.record_mutation();
345        outcome
346    }
347
348    /// Removes an analyzed file.
349    ///
350    /// A referenced node is demoted to a stub in the same slot. Unreferenced
351    /// nodes are freed, and outgoing reverse-dependency memberships are
352    /// removed incrementally.
353    pub fn remove_file(&mut self, path: &str) -> bool {
354        let Some(&slot) = self.by_path.get(path) else {
355            return false;
356        };
357        if matches!(self.occupied(slot).state, NodeState::Stub)
358            && !self.occupied(slot).rdeps.is_empty()
359        {
360            return true;
361        }
362
363        let was_exact = self.node_is_rdeps_exact(slot);
364        let old_targets = self.node_targets(slot);
365        self.update_rdeps(slot, &old_targets, &FxHashSet::default());
366
367        if self.occupied(slot).rdeps.is_empty() {
368            self.free_slot(slot);
369        } else {
370            let node = self.occupied_mut(slot);
371            node.state = NodeState::Stub;
372            node.out.clear();
373            node.config_dependencies.clear();
374            node.imports_supported = false;
375            node.resolver_live = false;
376            node.resolution_complete = false;
377            node.resolved_at = 0;
378            self.record_exactness_transition(was_exact, false);
379        }
380        self.record_mutation();
381        true
382    }
383
384    /// Re-resolves every analyzed node against a new resolver generation.
385    ///
386    /// Reverse dependencies are adjusted by target-set diffs for each node;
387    /// they are never rebuilt globally.
388    pub fn reresolve_all(&mut self, resolvers: &ResolverSet) {
389        self.resolver_generation += 1;
390        self.inexact_nodes = self.by_path.len();
391        let current_generation = self.resolver_generation;
392        let jobs: Vec<_> = self
393            .nodes
394            .iter()
395            .enumerate()
396            .filter_map(|(slot, node)| {
397                let node = node.as_ref()?;
398                let NodeState::Analyzed { language, .. } = &node.state else {
399                    return None;
400                };
401                Some((
402                    slot as u32,
403                    node.path.clone(),
404                    node.out
405                        .iter()
406                        .map(|edge| edge.raw.clone())
407                        .collect::<Vec<_>>(),
408                    language.clone(),
409                ))
410            })
411            .collect();
412
413        for (slot, path, imports, language) in jobs {
414            let resolutions = resolve_imports(resolvers, &path, &imports);
415            let resolver_live = resolver_is_live(language.as_deref(), &imports, resolvers);
416            let old_targets = self.node_targets(slot);
417            let baseline_completeness = language
418                .as_deref()
419                .map_or(ResolutionCompleteness::Complete, |language| {
420                    resolvers.baseline_completeness(language)
421                });
422            let (edges, config_dependencies, resolution_complete) =
423                self.materialize_resolutions(resolutions, baseline_completeness);
424            let new_targets = targets_from_edges(&edges);
425            self.update_rdeps(slot, &old_targets, &new_targets);
426
427            let node = self.occupied_mut(slot);
428            node.out = edges;
429            node.config_dependencies = config_dependencies;
430            node.resolver_live = resolver_live;
431            node.resolution_complete = resolution_complete;
432            node.resolved_at = current_generation;
433            let is_exact = self.node_is_rdeps_exact(slot);
434            self.record_exactness_transition(false, is_exact);
435        }
436        self.record_mutation();
437    }
438
439    /// Advances only the resolver generation, leaving current edges stale.
440    pub fn bump_resolver_generation(&mut self) {
441        self.resolver_generation += 1;
442        self.inexact_nodes = self.by_path.len();
443        self.record_mutation();
444    }
445
446    /// Returns the deduplicated union of resolver configuration dependencies.
447    #[must_use]
448    pub fn config_dependencies(&self) -> Vec<CompactString> {
449        let mut dependencies: Vec<_> = self
450            .nodes
451            .iter()
452            .flatten()
453            .flat_map(|node| node.config_dependencies.iter().cloned())
454            .collect::<FxHashSet<_>>()
455            .into_iter()
456            .collect();
457        dependencies.sort_unstable();
458        dependencies
459    }
460
461    /// Iterates over occupied node paths in slot order.
462    pub fn paths(&self) -> impl Iterator<Item = &str> {
463        self.nodes.iter().flatten().map(|node| node.path.as_str())
464    }
465
466    /// Iterates over every stored edge with graph slots materialized as paths.
467    pub fn edges(&self) -> impl Iterator<Item = DepEdge> + '_ {
468        self.nodes
469            .iter()
470            .enumerate()
471            .flat_map(move |(source, node)| {
472                let source =
473                    u32::try_from(source).expect("module graph slot must fit in its u32 key");
474                node.iter().flat_map(move |node| {
475                    node.out
476                        .iter()
477                        .map(move |edge| self.owned_edge(source, edge))
478                })
479            })
480    }
481
482    /// Returns the total number of resolved, external, and unresolved edges.
483    #[must_use]
484    pub fn edge_count(&self) -> usize {
485        self.nodes.iter().flatten().map(|node| node.out.len()).sum()
486    }
487
488    /// Returns direct outgoing dependencies for `path`.
489    #[must_use]
490    pub fn deps(&self, path: &str) -> Option<DepsResult> {
491        let slot = *self.by_path.get(path)?;
492        let node = self.occupied(slot);
493        let mut visited = FxHashSet::default();
494        visited.insert(slot);
495        let mut edges = Vec::with_capacity(node.out.len());
496        for edge in &node.out {
497            if let EdgeTarget::Node(target) = edge.target {
498                visited.insert(target);
499            }
500            edges.push(self.owned_edge(slot, edge));
501        }
502        sort_edges(&mut edges);
503
504        Some(DepsResult {
505            edges,
506            guarantee: self.deps_guarantee(slot),
507            coverage: self.coverage(&visited),
508        })
509    }
510
511    /// Reverse-dependency guarantee without materializing importer edges.
512    #[must_use]
513    pub fn rdeps_guarantee_for(&self, path: &str) -> Option<Guarantee> {
514        self.by_path.get(path)?;
515        Some(self.rdeps_guarantee())
516    }
517
518    /// Returns at most `limit` stored edges that directly target `path`.
519    #[must_use]
520    pub fn rdeps_bounded(&self, path: &str, limit: usize) -> Option<DepsResult> {
521        let target = *self.by_path.get(path)?;
522        let node = self.occupied(target);
523        let mut visited = FxHashSet::default();
524        visited.insert(target);
525        let mut edges = Vec::new();
526        'sources: for &source in &node.rdeps {
527            let source_node = self.occupied(source);
528            visited.insert(source);
529            for edge in source_node
530                .out
531                .iter()
532                .filter(|edge| edge.target == EdgeTarget::Node(target))
533            {
534                if edges.len() >= limit {
535                    break 'sources;
536                }
537                edges.push(self.owned_edge(source, edge));
538            }
539        }
540        sort_edges(&mut edges);
541        Some(DepsResult {
542            edges,
543            guarantee: self.rdeps_guarantee(),
544            coverage: self.coverage(&visited),
545        })
546    }
547
548    /// Returns all stored edges that directly target `path`.
549    #[must_use]
550    pub fn rdeps(&self, path: &str) -> Option<DepsResult> {
551        let target = *self.by_path.get(path)?;
552        let node = self.occupied(target);
553        let mut visited = FxHashSet::default();
554        visited.insert(target);
555        let mut edges = Vec::new();
556        for &source in &node.rdeps {
557            let source_node = self.occupied(source);
558            visited.insert(source);
559            edges.extend(
560                source_node
561                    .out
562                    .iter()
563                    .filter(|edge| edge.target == EdgeTarget::Node(target))
564                    .map(|edge| self.owned_edge(source, edge)),
565            );
566        }
567        sort_edges(&mut edges);
568
569        Some(DepsResult {
570            edges,
571            guarantee: self.rdeps_guarantee(),
572            coverage: self.coverage(&visited),
573        })
574    }
575
576    /// Traverses both dependency directions to `depth` edges from `path`.
577    #[must_use]
578    pub fn neighborhood(&self, path: &str, depth: u32) -> Option<NeighborhoodResult> {
579        let center = *self.by_path.get(path)?;
580        let mut visited = FxHashSet::default();
581        let mut queue = VecDeque::new();
582        visited.insert(center);
583        queue.push_back((center, 0_u32));
584
585        while let Some((slot, distance)) = queue.pop_front() {
586            if distance == depth {
587                continue;
588            }
589            let node = self.occupied(slot);
590            let neighbors = node
591                .out
592                .iter()
593                .filter_map(|edge| match edge.target {
594                    EdgeTarget::Node(target) => Some(target),
595                    EdgeTarget::External(_) | EdgeTarget::Unresolved(_) => None,
596                })
597                .chain(node.rdeps.iter().copied())
598                .collect::<Vec<_>>();
599            for neighbor in neighbors {
600                if visited.insert(neighbor) {
601                    queue.push_back((neighbor, distance + 1));
602                }
603            }
604        }
605
606        let mut nodes: Vec<_> = visited
607            .iter()
608            .map(|&slot| self.occupied(slot).path.clone())
609            .collect();
610        nodes.sort_unstable();
611
612        let mut edges = Vec::new();
613        if depth != 0 {
614            for &source in &visited {
615                edges.extend(
616                    self.occupied(source)
617                        .out
618                        .iter()
619                        .filter(|edge| {
620                            matches!(edge.target, EdgeTarget::Node(target) if visited.contains(&target))
621                        })
622                        .map(|edge| self.owned_edge(source, edge)),
623                );
624            }
625        }
626        sort_edges(&mut edges);
627
628        let guarantee = visited
629            .iter()
630            .fold(Guarantee::Exact, |guarantee, &slot| {
631                guarantee.weakest(self.deps_guarantee(slot))
632            })
633            .weakest(if depth == 0 {
634                Guarantee::Exact
635            } else {
636                self.rdeps_guarantee()
637            });
638
639        Some(NeighborhoodResult {
640            nodes,
641            edges,
642            guarantee,
643            coverage: self.coverage(&visited),
644        })
645    }
646
647    fn deps_guarantee(&self, slot: u32) -> Guarantee {
648        let node = self.occupied(slot);
649        let exact = matches!(
650            node.state,
651            NodeState::Analyzed {
652                has_opaque_imports: false,
653                ..
654            }
655        ) && node.imports_supported
656            && node.resolver_live
657            && node.resolution_complete
658            // Edges resolved under an older resolver configuration may point
659            // at the wrong targets until reresolve_all catches up.
660            && node.resolved_at == self.resolver_generation;
661        if exact {
662            Guarantee::Exact
663        } else {
664            Guarantee::Approximate
665        }
666    }
667
668    fn rdeps_guarantee(&self) -> Guarantee {
669        let exact = self.universe_complete && self.inexact_nodes == 0;
670        if exact {
671            Guarantee::Exact
672        } else {
673            Guarantee::Approximate
674        }
675    }
676
677    fn coverage(&self, slots: &FxHashSet<u32>) -> Coverage {
678        let mut ordered: Vec<_> = slots.iter().map(|&slot| self.occupied(slot)).collect();
679        ordered.sort_unstable_by(|left, right| left.path.cmp(&right.path));
680
681        let mut coverage = Coverage::default();
682        for node in ordered {
683            match &node.state {
684                NodeState::Analyzed {
685                    content_hash,
686                    has_opaque_imports,
687                    ..
688                } => {
689                    coverage.analyzed += 1;
690                    coverage.opaque_files += u64::from(*has_opaque_imports);
691                    coverage.basis.push((node.path.clone(), *content_hash));
692                }
693                NodeState::Stub => coverage.stubs += 1,
694            }
695        }
696        coverage
697    }
698
699    fn materialize_resolutions(
700        &mut self,
701        resolutions: Vec<(RawImport, ResolutionOutcome)>,
702        baseline_completeness: ResolutionCompleteness,
703    ) -> (Vec<ImportEdge>, Vec<CompactString>, bool) {
704        let mut dependencies = FxHashSet::default();
705        let mut resolution_complete = baseline_completeness == ResolutionCompleteness::Complete;
706        let edges = resolutions
707            .into_iter()
708            .map(|(raw, outcome)| {
709                resolution_complete &= outcome.completeness == ResolutionCompleteness::Complete;
710                dependencies.extend(outcome.dependencies);
711                let target = match outcome.resolved {
712                    Resolved::Path(path) => EdgeTarget::Node(self.ensure_stub(path)),
713                    Resolved::External(package) => EdgeTarget::External(package),
714                    Resolved::Unresolved(reason) => EdgeTarget::Unresolved(reason),
715                };
716                ImportEdge { raw, target }
717            })
718            .collect();
719        let mut dependencies: Vec<_> = dependencies.into_iter().collect();
720        dependencies.sort_unstable();
721        (edges, dependencies, resolution_complete)
722    }
723
724    fn ensure_stub(&mut self, path: CompactString) -> u32 {
725        self.by_path
726            .get(path.as_str())
727            .copied()
728            .unwrap_or_else(|| self.allocate(ModuleNode::stub(path)))
729    }
730
731    fn allocate(&mut self, node: ModuleNode) -> u32 {
732        let exact = rdeps_node_is_exact(&node, self.resolver_generation);
733        let path = node.path.clone();
734        let slot = if let Some(slot) = self.free.pop() {
735            debug_assert!(self.nodes[slot as usize].is_none());
736            self.nodes[slot as usize] = Some(node);
737            slot
738        } else {
739            let slot =
740                u32::try_from(self.nodes.len()).expect("module graph exhausted its u32 slot space");
741            self.nodes.push(Some(node));
742            slot
743        };
744        self.by_path.insert(path, slot);
745        self.inexact_nodes += usize::from(!exact);
746        slot
747    }
748
749    fn free_slot(&mut self, slot: u32) {
750        let node = self.nodes[slot as usize]
751            .take()
752            .expect("slot to free must be occupied");
753        if !rdeps_node_is_exact(&node, self.resolver_generation) {
754            self.inexact_nodes -= 1;
755        }
756        let removed = self.by_path.remove(node.path.as_str());
757        debug_assert_eq!(removed, Some(slot));
758        self.free.push(slot);
759    }
760
761    fn prune_orphan_stub(&mut self, slot: u32) {
762        let should_prune = self.nodes[slot as usize]
763            .as_ref()
764            .is_some_and(|node| matches!(node.state, NodeState::Stub) && node.rdeps.is_empty());
765        if should_prune {
766            self.free_slot(slot);
767        }
768    }
769
770    fn node_targets(&self, slot: u32) -> FxHashSet<u32> {
771        targets_from_edges(&self.occupied(slot).out)
772    }
773
774    fn update_rdeps(
775        &mut self,
776        source: u32,
777        old_targets: &FxHashSet<u32>,
778        new_targets: &FxHashSet<u32>,
779    ) {
780        for &target in new_targets.difference(old_targets) {
781            self.occupied_mut(target).rdeps.insert(source);
782        }
783        let removed: Vec<_> = old_targets.difference(new_targets).copied().collect();
784        for &target in &removed {
785            self.occupied_mut(target).rdeps.remove(&source);
786        }
787        for target in removed {
788            self.prune_orphan_stub(target);
789        }
790    }
791
792    fn owned_edge(&self, source: u32, edge: &ImportEdge) -> DepEdge {
793        let raw = &edge.raw;
794        let to = match &edge.target {
795            EdgeTarget::Node(target) => EdgeTargetOwned::Path(self.occupied(*target).path.clone()),
796            EdgeTarget::External(package) => EdgeTargetOwned::External(package.clone()),
797            EdgeTarget::Unresolved(reason) => EdgeTargetOwned::Unresolved(reason.clone()),
798        };
799        DepEdge {
800            from: self.occupied(source).path.clone(),
801            to,
802            specifier: raw.specifier.clone(),
803            kind: raw.kind,
804            line: raw.line,
805            span: raw.span,
806        }
807    }
808
809    fn occupied(&self, slot: u32) -> &ModuleNode {
810        self.nodes[slot as usize]
811            .as_ref()
812            .expect("graph edge must reference an occupied slot")
813    }
814
815    fn occupied_mut(&mut self, slot: u32) -> &mut ModuleNode {
816        self.nodes[slot as usize]
817            .as_mut()
818            .expect("graph edge must reference an occupied slot")
819    }
820
821    fn node_is_rdeps_exact(&self, slot: u32) -> bool {
822        rdeps_node_is_exact(self.occupied(slot), self.resolver_generation)
823    }
824
825    fn record_exactness_transition(&mut self, was_exact: bool, is_exact: bool) {
826        match (was_exact, is_exact) {
827            (false, true) => self.inexact_nodes -= 1,
828            (true, false) => self.inexact_nodes += 1,
829            (false, false) | (true, true) => {}
830        }
831    }
832
833    fn record_mutation(&mut self) {
834        self.generation += 1;
835    }
836}
837
838fn rdeps_node_is_exact(node: &ModuleNode, resolver_generation: u64) -> bool {
839    matches!(
840        node.state,
841        NodeState::Analyzed {
842            has_opaque_imports: false,
843            ..
844        }
845    ) && node.imports_supported
846        && node.resolver_live
847        && node.resolution_complete
848        && node.resolved_at == resolver_generation
849}
850
851fn resolve_imports(
852    resolvers: &ResolverSet,
853    path: &str,
854    imports: &[RawImport],
855) -> Vec<(RawImport, ResolutionOutcome)> {
856    imports
857        .iter()
858        .cloned()
859        .map(|raw| {
860            let outcome = resolvers.resolve(path, &raw);
861            (raw, outcome)
862        })
863        .collect()
864}
865
866fn resolver_is_live(
867    language: Option<&str>,
868    imports: &[RawImport],
869    resolvers: &ResolverSet,
870) -> bool {
871    match language {
872        Some("rust") => resolvers.rust.is_some(),
873        Some("typescript" | "tsx" | "javascript" | "jsx" | "vue") => resolvers.js.is_some(),
874        Some(_) if !imports.is_empty() => imports.iter().all(|raw| match raw.kind {
875            ImportKind::RustUse | ImportKind::RustMod => resolvers.rust.is_some(),
876            _ => resolvers.js.is_some(),
877        }),
878        Some(_) | None => false,
879    }
880}
881
882fn targets_from_edges(edges: &[ImportEdge]) -> FxHashSet<u32> {
883    edges
884        .iter()
885        .filter_map(|edge| match edge.target {
886            EdgeTarget::Node(target) => Some(target),
887            EdgeTarget::External(_) | EdgeTarget::Unresolved(_) => None,
888        })
889        .collect()
890}
891
892fn sort_edges(edges: &mut [DepEdge]) {
893    edges.sort_by(|left, right| {
894        left.from
895            .cmp(&right.from)
896            .then(left.line.cmp(&right.line))
897            .then(left.span.0.cmp(&right.span.0))
898            .then(left.span.1.cmp(&right.span.1))
899            .then(left.specifier.cmp(&right.specifier))
900    });
901}