Skip to main content

gonzalo_graph/
resolve.rs

1//! Best-effort name resolution over an assembled view (ticket G).
2//!
3//! The base graph is a **heuristic** call graph: references match a target by
4//! name, so `callers_of("foo")` returns callers of *any* `foo`. This layer
5//! resolves each reference to the specific defining path it most likely means,
6//! disambiguating same-named symbols across files:
7//!
8//! 1. **Qualified** — the call site named an owner (`Beta::get()`) and exactly
9//!    one definition in the view is owned by it (#248).
10//! 2. **Local** — a definition of the name in the reference's own file wins.
11//! 3. **Unique global** — otherwise, the sole definition across the view.
12//! 4. **Ambiguous** — multiple definitions and none local: left unresolved.
13//! 5. **Unresolved** — no definition in the view (honest dangling, ADR 0012).
14//!
15//! The qualifier rule only ever *narrows*. A Rust qualifier is as often a
16//! module as a type, and most modules are files rather than inline `mod`
17//! blocks, so nothing in the view is owned by them; declining those edges would
18//! lose attributions the view makes correctly today. When a qualifier matches
19//! no owner it is ignored and the remaining rules apply unchanged.
20//!
21//! Resolution is file-scoped (not yet import-aware); it is a pure function of a
22//! [`GraphStore`]'s query methods, so it works over any backend and adds no
23//! trait surface. Import-following resolution is a further step, and is also
24//! what would let a module path be told from a type — at which point an
25//! unmatched qualifier could decline rather than fall through (#252).
26
27use crate::{GraphStore, Import, Located, RefKind, Reference};
28use serde::{Deserialize, Serialize};
29use std::collections::{BTreeMap, BTreeSet};
30
31/// How a reference was resolved to a definition.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Resolution {
34    /// The call site named a qualifier (`Beta::get()`) and exactly one
35    /// definition in the view is owned by it.
36    ///
37    /// Outranks [`Local`](Resolution::Local): a call that says which type it
38    /// means says so whatever else sits in the same file (#248).
39    Qualified,
40    /// A definition of the name exists in the reference's own file.
41    Local,
42    /// The name is defined exactly once across the view.
43    UniqueGlobal,
44    /// Several definitions, none local, and the referencing file imports the
45    /// name from a module that exactly one of them lives under (#252).
46    Imported,
47    /// Several definitions and none in the reference's file — not resolved.
48    Ambiguous,
49    /// A method call (`x.foo()`) whose receiver type is unknown, so no
50    /// definition in the view can be claimed even if exactly one exists.
51    ///
52    /// Distinct from [`Unresolved`](Resolution::Unresolved): definitions of the
53    /// name may well be present, but attributing the call to one of them would
54    /// assert a receiver type the graph does not know (#223).
55    ReceiverUnknown,
56    /// No definition of the name in the view — a dangling reference.
57    Unresolved,
58}
59
60/// A reference resolved (best-effort) to the path of the symbol it refers to.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ResolvedReference {
63    /// The reference and the path it was found in.
64    pub reference: Located<Reference>,
65    /// The defining path this reference resolves to, or `None` when ambiguous
66    /// or unresolved.
67    pub target: Option<String>,
68    /// Why it resolved (or didn't).
69    pub resolution: Resolution,
70}
71
72/// Resolve every reference to `name` to a defining path (see the module docs
73/// for the strategy).
74pub fn resolve_references_to(store: &dyn GraphStore, name: &str) -> Vec<ResolvedReference> {
75    let defs = store.definitions(name);
76    let def_paths: BTreeSet<String> = defs.iter().map(|d| d.path.clone()).collect();
77    let references = store.references_to(name);
78
79    // Imports only matter where the name is ambiguous, and only for the files
80    // that actually reference it — so fetch once per such file rather than once
81    // per call site (#252).
82    let mut imports_by_path: BTreeMap<String, Vec<Import>> = BTreeMap::new();
83    if def_paths.len() > 1 {
84        for located in &references {
85            if !imports_by_path.contains_key(&located.path) {
86                imports_by_path.insert(located.path.clone(), store.imports_in_file(&located.path));
87            }
88        }
89    }
90
91    references
92        .into_iter()
93        .map(|located| {
94            // A call that names its owner (`Beta::get()`) says which definition
95            // it means, whatever else happens to share the name — so this is
96            // consulted before the local rule.
97            //
98            // Only when the qualifier actually matches an owner in the view. A
99            // file module (`some_module::helper()`) owns nothing here, and
100            // declining that edge would throw away an attribution the view can
101            // make perfectly well. So the qualifier only ever *narrows*: it can
102            // turn ambiguity into an answer, never an answer into ambiguity
103            // (#248). Telling a module path from a type is what #252 is for.
104            let qualified: BTreeSet<&str> = match located.item.qualifier.as_deref() {
105                Some(q) => defs
106                    .iter()
107                    .filter(|d| d.item.owner.as_deref() == Some(q))
108                    .map(|d| d.path.as_str())
109                    .collect(),
110                None => BTreeSet::new(),
111            };
112
113            let (target, resolution) = if qualified.len() == 1 {
114                (
115                    qualified.iter().next().map(|p| (*p).to_string()),
116                    Resolution::Qualified,
117                )
118            } else if !qualified.is_empty() {
119                // Same owner name defined in several files: the qualifier
120                // narrowed the candidates but did not settle them.
121                (None, Resolution::Ambiguous)
122            } else if def_paths.contains(&located.path) {
123                // A definition in the caller's own file wins for either shape:
124                // `self.foo()` next to `fn foo` is the one thing about a
125                // receiver we can reasonably assume.
126                (Some(located.path.clone()), Resolution::Local)
127            } else if def_paths.is_empty() {
128                // Nothing of this name anywhere: "not in the view" is the more
129                // informative answer than "receiver unknown", and it is true
130                // whatever the call shape.
131                (None, Resolution::Unresolved)
132            } else if located.item.kind == RefKind::Method {
133                // `x.foo()` belongs to whatever `x` is. Measured on gonzalo,
134                // 294 of the 388 cross-file method calls that used to resolve
135                // `UniqueGlobal` pointed at a *different crate* — `push`,
136                // `filter`, `send`, `next` and friends, i.e. std methods
137                // attributed to a same-named project function (#223).
138                (None, Resolution::ReceiverUnknown)
139            } else if def_paths.len() == 1 {
140                (def_paths.iter().next().cloned(), Resolution::UniqueGlobal)
141            } else if let Some(path) = imports_by_path
142                .get(&located.path)
143                .and_then(|imports| imported_target(imports, name, &located.path, &def_paths))
144            {
145                (Some(path), Resolution::Imported)
146            } else {
147                (None, Resolution::Ambiguous)
148            };
149            ResolvedReference {
150                reference: located,
151                target,
152                resolution,
153            }
154        })
155        .collect()
156}
157
158/// The single candidate an import in the referencing file points at, or `None`
159/// when the file imports the name from nowhere or the import fits several
160/// candidates.
161///
162/// Narrowing only: an import that does not settle the question is ignored and
163/// the reference stays ambiguous, which is the honest answer and keeps this from
164/// ever turning a resolved edge into an unresolved one (#248, #252).
165fn imported_target(
166    imports: &[Import],
167    name: &str,
168    from_path: &str,
169    candidates: &BTreeSet<String>,
170) -> Option<String> {
171    let Some(import) = imports.iter().find(|i| i.name == name) else {
172        // No import names this symbol. A C/C++ `#include` names a whole file
173        // instead, so match by path: any candidate under an included path is
174        // what this file meant by the name (#267).
175        return included_target(imports, from_path, candidates);
176    };
177
178    let matched: Vec<&str> = if import.depth > 0 {
179        // A relative import needs no project root: count dots up from the
180        // referencing file's own package and the answer is a path (#261).
181        let anchor = relative_anchor(from_path, import.depth, &import.path)?;
182        candidates
183            .iter()
184            .filter(|path| path_under(path, &anchor))
185            .map(String::as_str)
186            .collect()
187    } else {
188        if import.path.is_empty() {
189            return None;
190        }
191        candidates
192            .iter()
193            .filter(|path| path_ends_with_module(path, &import.path))
194            .map(String::as_str)
195            .collect()
196    };
197
198    match matched.as_slice() {
199        [] => None,
200        [only] => Some((*only).to_string()),
201        // Several identical copies of one package tree — one per assignment,
202        // a vendored reference beside your own code. The nearest is meant.
203        several => nearest_to(several, from_path),
204    }
205}
206
207/// The single candidate covered by one of this file's whole-file imports.
208///
209/// An include names a path, and dropping the extension is what makes it useful:
210/// a prototype in a header is not a symbol, the definition in the translation
211/// unit is, and both sit at the same path stem. Narrowing only, like every
212/// other rule here — several matches leave the reference ambiguous unless one
213/// is unambiguously nearer.
214fn included_target(
215    imports: &[Import],
216    from_path: &str,
217    candidates: &BTreeSet<String>,
218) -> Option<String> {
219    let included: Vec<&Import> = imports
220        .iter()
221        .filter(|i| i.brings_whole_file() && !i.path.is_empty())
222        .collect();
223    if included.is_empty() {
224        return None;
225    }
226    let matched: Vec<&str> = candidates
227        .iter()
228        .filter(|path| {
229            included
230                .iter()
231                .any(|import| path_ends_with_module(path, &import.path))
232        })
233        .map(String::as_str)
234        .collect();
235    match matched.as_slice() {
236        [] => None,
237        [only] => Some((*only).to_string()),
238        several => nearest_to(several, from_path),
239    }
240}
241
242/// The directory a relative import points at: the referencing file's own
243/// package, one level up per dot beyond the first, then the module segments.
244///
245/// `None` when the path has fewer levels than the import claims, which leaves
246/// the older rules to run unchanged rather than dropping the edge.
247fn relative_anchor(from_path: &str, depth: usize, module: &[String]) -> Option<Vec<String>> {
248    let mut components: Vec<&str> = from_path.split('/').filter(|c| !c.is_empty()).collect();
249    components.pop()?; // the file itself; what remains is its package
250    for _ in 1..depth {
251        components.pop()?;
252    }
253    let mut anchor: Vec<String> = components.into_iter().map(str::to_string).collect();
254    anchor.extend(module.iter().cloned());
255    Some(anchor)
256}
257
258/// Whether `file` sits at or under the package directory `anchor`.
259fn path_under(file: &str, anchor: &[String]) -> bool {
260    let mut components: Vec<&str> = file.split('/').filter(|c| !c.is_empty()).collect();
261    if let Some(last) = components.pop() {
262        let stem = last.rsplit_once('.').map_or(last, |(stem, _)| stem);
263        // `pkg/__init__.py` *is* `pkg`, so it adds no segment of its own.
264        if stem != "__init__" {
265            components.push(stem);
266        }
267    }
268    components.len() >= anchor.len()
269        && components
270            .iter()
271            .zip(anchor)
272            .all(|(component, segment)| same_segment(component, segment))
273}
274
275/// The one candidate sharing the longest path prefix with `from_path`.
276///
277/// `None` on a tie, and `None` when nothing shares a prefix at all: two copies
278/// equally far away say nothing about which was meant, and guessing there is
279/// the coin flip this whole layer exists to avoid.
280fn nearest_to(candidates: &[&str], from_path: &str) -> Option<String> {
281    let from: Vec<&str> = from_path.split('/').filter(|c| !c.is_empty()).collect();
282    let shared = |path: &str| {
283        path.split('/')
284            .filter(|c| !c.is_empty())
285            .zip(from.iter())
286            .take_while(|(component, own)| same_segment(component, own))
287            .count()
288    };
289    let best = candidates.iter().map(|path| shared(path)).max()?;
290    if best == 0 {
291        return None;
292    }
293    let mut winners = candidates.iter().filter(|path| shared(path) == best);
294    let only = winners.next()?;
295    winners.next().is_none().then(|| (*only).to_string())
296}
297
298/// Whether `file`'s path components end with the module segments `module`.
299///
300/// `src/model.rs` ends with `["model"]`; `a/b/c.rs` ends with `["b", "c"]`.
301///
302/// Three conventions are folded in because they are near-universal and the rule
303/// is close to useless without them. An entry file names its container rather
304/// than itself, so `mod.rs`, `lib.rs`, `main.rs`, `__init__.py` and `index.ts`
305/// contribute no segment of their own. A `src` directory is build layout rather
306/// than a module, so it is skipped — `my-pkg/src/model.rs` is `my_pkg::model`.
307/// And segments compare with `-` and `_` alike, because a Rust crate is written
308/// `gonzalo_cli` in code and lives in `gonzalo-cli` on disk.
309fn path_ends_with_module(file: &str, module: &[String]) -> bool {
310    let mut components: Vec<&str> = file
311        .split('/')
312        .filter(|c| !c.is_empty() && *c != "src")
313        .collect();
314    if let Some(last) = components.pop() {
315        let stem = last.rsplit_once('.').map_or(last, |(stem, _)| stem);
316        if !matches!(stem, "mod" | "lib" | "main" | "__init__" | "index") {
317            components.push(stem);
318        }
319    }
320    components.len() >= module.len()
321        && components[components.len() - module.len()..]
322            .iter()
323            .zip(module)
324            .all(|(component, segment)| same_segment(component, segment))
325}
326
327/// Whether two path segments name the same thing, treating `-` and `_` alike.
328fn same_segment(component: &str, segment: &str) -> bool {
329    component.len() == segment.len()
330        && component
331            .bytes()
332            .zip(segment.bytes())
333            .all(|(a, b)| a == b || (a == b'-' || a == b'_') && (b == b'-' || b == b'_'))
334}
335
336/// Enclosing functions that call the `name` **defined at `defining_path`** — the
337/// precision refinement of [`GraphStore::callers_of`], which returns callers of
338/// any same-named symbol. Sorted and deduped.
339pub fn resolved_callers_of(store: &dyn GraphStore, defining_path: &str, name: &str) -> Vec<String> {
340    let mut callers: Vec<String> = resolve_references_to(store, name)
341        .into_iter()
342        .filter(|r| r.target.as_deref() == Some(defining_path))
343        .filter_map(|r| r.reference.item.from)
344        .collect();
345    callers.sort();
346    callers.dedup();
347    callers
348}
349
350/// One symbol reached by an impact closure, identified by the path that defines
351/// it rather than by name alone.
352#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
353pub struct ImpactNode {
354    pub name: String,
355    /// The file defining this symbol. Two same-named symbols in different files
356    /// are different nodes — that distinction is the whole point (#207).
357    pub path: String,
358}
359
360/// The result of a resolution-gated impact walk.
361#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
362pub struct ImpactReport {
363    /// Symbols transitively affected, sorted, excluding the seed.
364    pub reached: Vec<ImpactNode>,
365    /// Call edges dropped because the name has several definitions and none is
366    /// local. Reported rather than hidden: silently truncating a closure is its
367    /// own kind of lie, and a non-zero count here means the true impact set may
368    /// be larger than `reached`.
369    pub ambiguous_edges: usize,
370    /// Call edges dropped because they are method calls on a receiver of unknown
371    /// type ([`Resolution::ReceiverUnknown`]). Counted separately from
372    /// `ambiguous_edges` because the cause differs: not "too many candidates"
373    /// but "cannot claim any candidate" (#223).
374    pub receiver_unknown_edges: usize,
375    /// Call-graph edges skipped because the reference names a function as a
376    /// *value* rather than calling it — `register(helper)`.
377    ///
378    /// Passing a function is a real dependency, so dropping these silently
379    /// would under-report. But a value reference is over-inclusive by
380    /// construction: extraction is per-file, so a local named like a function is
381    /// indistinguishable from the function, and traversing them would put false
382    /// edges back into the walk #207 and #223 cleaned up. Counted instead, so
383    /// non-zero means "go look" rather than nothing at all (#250).
384    pub value_edges: usize,
385    /// Whether the walk stopped at `max_depth` with unexplored frontier left.
386    ///
387    /// This means "the set may be incomplete", not "more definitely exists":
388    /// a node reached on the last permitted level was never asked for its own
389    /// callers, so completeness cannot be claimed either way.
390    pub truncated: bool,
391}
392
393/// The transitive closure of callers of `name`, following only edges that
394/// resolve to a specific definition.
395///
396/// [`GraphStore::impact`](crate::GraphStore::impact) walks the name-matched
397/// graph, so one hop into a name with several unrelated definitions absorbs
398/// every subgraph sharing that identifier — on gonzalo itself a single seed
399/// reached a quarter of the repository (#207). This walk keys nodes on
400/// `(name, defining path)` and consults [`resolve_references_to`] for every
401/// edge, so an [`Ambiguous`](Resolution::Ambiguous) reference is counted and
402/// dropped instead of merging two graphs.
403///
404/// A caller's own path needs no resolution: the enclosing function of a call is
405/// by definition in the file containing that call, so each reached node gets an
406/// exact path.
407///
408/// This gates [`Ambiguous`](Resolution::Ambiguous) edges only. A name defined
409/// exactly once still resolves [`UniqueGlobal`](Resolution::UniqueGlobal) even
410/// when the call really meant a std or dependency method of the same name, which
411/// remains a source of false edges (#223).
412///
413/// `max_depth` bounds the walk (`None` = unbounded); an ambiguous seed is walked
414/// from each of its definitions, since the caller asked about all of them.
415pub fn resolved_impact(
416    store: &dyn GraphStore,
417    name: &str,
418    max_depth: Option<usize>,
419) -> ImpactReport {
420    let seeds: Vec<ImpactNode> = store
421        .definitions(name)
422        .into_iter()
423        .map(|d| ImpactNode {
424            name: name.to_string(),
425            path: d.path,
426        })
427        .collect();
428
429    let mut visited: BTreeSet<ImpactNode> = seeds.iter().cloned().collect();
430    let mut frontier: Vec<ImpactNode> = seeds.clone();
431    let mut report = ImpactReport::default();
432    let mut depth = 0usize;
433
434    while !frontier.is_empty() {
435        if max_depth.is_some_and(|max| depth >= max) {
436            report.truncated = true;
437            break;
438        }
439        depth += 1;
440
441        let mut next: Vec<ImpactNode> = Vec::new();
442        for node in &frontier {
443            for resolved in resolve_references_to(store, &node.name) {
444                // Naming a function is not calling it. Real dependency, but too
445                // over-inclusive to traverse — report it instead (#250).
446                if resolved.reference.item.kind == RefKind::Value {
447                    report.value_edges += 1;
448                    continue;
449                }
450                match resolved.resolution {
451                    // Unattributable: report it, do not traverse it.
452                    Resolution::Ambiguous => report.ambiguous_edges += 1,
453                    Resolution::ReceiverUnknown => report.receiver_unknown_edges += 1,
454                    // Resolves elsewhere, or nowhere — not an edge into `node`.
455                    _ if resolved.target.as_deref() != Some(node.path.as_str()) => {}
456                    _ => {
457                        let Some(from) = resolved.reference.item.from else {
458                            continue; // a top-level reference has no caller
459                        };
460                        let caller = ImpactNode {
461                            name: from,
462                            path: resolved.reference.path,
463                        };
464                        if visited.insert(caller.clone()) {
465                            next.push(caller);
466                        }
467                    }
468                }
469            }
470        }
471        frontier = next;
472    }
473
474    for seed in &seeds {
475        visited.remove(seed);
476    }
477    report.reached = visited.into_iter().collect();
478    report
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::{InMemoryGraphStore, build_rust};
485
486    fn view() -> InMemoryGraphStore {
487        let mut s = InMemoryGraphStore::new();
488        // Two files each define `foo` and call it locally.
489        s.insert(
490            "a.rs",
491            build_rust("fn foo() {}\nfn ca() { foo(); }\nfn only() {}"),
492        );
493        s.insert("b.rs", build_rust("fn foo() {}\nfn cb() { foo(); }"));
494        // A cross-file call to `only` (defined once, in a.rs).
495        s.insert("c.rs", build_rust("fn cc() { only(); }"));
496        // A call to `foo` from a file that does not define it (ambiguous).
497        s.insert("d.rs", build_rust("fn cd() { foo(); }"));
498        s
499    }
500
501    #[test]
502    fn local_definition_wins() {
503        let s = view();
504        let resolved = resolve_references_to(&s, "foo");
505        // ca -> a.rs's foo, cb -> b.rs's foo (each local).
506        let ca = resolved
507            .iter()
508            .find(|r| r.reference.item.from.as_deref() == Some("ca"))
509            .unwrap();
510        assert_eq!(ca.resolution, Resolution::Local);
511        assert_eq!(ca.target.as_deref(), Some("a.rs"));
512        let cb = resolved
513            .iter()
514            .find(|r| r.reference.item.from.as_deref() == Some("cb"))
515            .unwrap();
516        assert_eq!(cb.target.as_deref(), Some("b.rs"));
517    }
518
519    #[test]
520    fn unique_global_resolves() {
521        let s = view();
522        let resolved = resolve_references_to(&s, "only");
523        assert_eq!(resolved.len(), 1);
524        assert_eq!(resolved[0].resolution, Resolution::UniqueGlobal);
525        assert_eq!(resolved[0].target.as_deref(), Some("a.rs"));
526    }
527
528    #[test]
529    fn multiple_defs_without_a_local_are_ambiguous() {
530        let s = view();
531        let cd = resolve_references_to(&s, "foo")
532            .into_iter()
533            .find(|r| r.reference.item.from.as_deref() == Some("cd"))
534            .unwrap();
535        assert_eq!(cd.resolution, Resolution::Ambiguous);
536        assert_eq!(cd.target, None);
537    }
538
539    #[test]
540    fn missing_definition_is_unresolved() {
541        let s = view();
542        let mut s = s;
543        s.insert("e.rs", build_rust("fn ce() { ghost(); }"));
544        let resolved = resolve_references_to(&s, "ghost");
545        assert_eq!(resolved.len(), 1);
546        assert_eq!(resolved[0].resolution, Resolution::Unresolved);
547        assert_eq!(resolved[0].target, None);
548    }
549
550    // ---- method calls do not claim a same-named free function (#223) ------
551
552    /// The exact shape found in the wild: a std method (`Iterator::chain`)
553    /// called in one crate, and a same-named test fixture that is the view's
554    /// only definition of `chain`.
555    fn std_method_collision() -> InMemoryGraphStore {
556        let mut s = InMemoryGraphStore::new();
557        s.insert(
558            "crates/core/src/merge.rs",
559            build_rust("fn merge() { let _ = ours.keys().chain(theirs.keys()); }"),
560        );
561        s.insert(
562            "crates/graph/src/store.rs",
563            build_rust("fn chain() -> u8 { 0 }"),
564        );
565        s
566    }
567
568    #[test]
569    fn a_method_call_does_not_resolve_to_a_same_named_free_function() {
570        let s = std_method_collision();
571        let r = resolve_references_to(&s, "chain");
572        let call = r
573            .iter()
574            .find(|r| r.reference.path.contains("merge.rs"))
575            .expect("the .chain() call is recorded");
576        assert_eq!(call.resolution, Resolution::ReceiverUnknown);
577        assert_eq!(call.target, None, "must not claim the test fixture");
578    }
579
580    #[test]
581    fn the_collision_no_longer_bridges_the_impact_closure() {
582        // Before: `chain` resolved UniqueGlobal, so seeding at the fixture
583        // dragged `merge` — in a crate that cannot depend on this one — in.
584        let report = resolved_impact(&std_method_collision(), "chain", None);
585        assert!(
586            !names_of(&report).contains(&"merge"),
587            "must not cross into the other crate: {report:?}"
588        );
589        assert_eq!(report.receiver_unknown_edges, 1, "and must say so");
590    }
591
592    #[test]
593    fn a_free_call_still_resolves_unique_global() {
594        // The fix must not disarm ordinary resolution.
595        let mut s = InMemoryGraphStore::new();
596        s.insert("a.rs", build_rust("fn only() {}"));
597        s.insert("b.rs", build_rust("fn cb() { only(); }"));
598        let r = resolve_references_to(&s, "only");
599        assert_eq!(r[0].resolution, Resolution::UniqueGlobal);
600        assert_eq!(r[0].target.as_deref(), Some("a.rs"));
601    }
602
603    #[test]
604    fn a_method_call_still_resolves_locally() {
605        // `self.helper()` beside `fn helper` is the one receiver assumption
606        // worth making, so same-file method calls keep resolving.
607        let mut s = InMemoryGraphStore::new();
608        s.insert(
609            "a.rs",
610            build_rust("fn helper() {}\nfn caller() { self.helper(); }"),
611        );
612        let r = resolve_references_to(&s, "helper");
613        let call = r.iter().find(|r| r.reference.item.from.is_some()).unwrap();
614        assert_eq!(call.resolution, Resolution::Local);
615        assert_eq!(call.target.as_deref(), Some("a.rs"));
616    }
617
618    #[test]
619    fn a_path_call_is_not_treated_as_a_method_call() {
620        // `a::b::foo()` is a path, not a receiver — it stays resolvable.
621        let mut s = InMemoryGraphStore::new();
622        s.insert("a.rs", build_rust("fn parse() {}"));
623        s.insert("b.rs", build_rust("fn cb() { util::parse(); }"));
624        let r = resolve_references_to(&s, "parse");
625        let call = r.iter().find(|r| r.reference.path == "b.rs").unwrap();
626        assert_eq!(call.resolution, Resolution::UniqueGlobal);
627    }
628
629    #[test]
630    fn receiver_unknown_is_distinct_from_unresolved() {
631        // A name with no definition at all is still Unresolved — the two mean
632        // different things and must stay distinguishable.
633        let mut s = InMemoryGraphStore::new();
634        s.insert("a.rs", build_rust("fn c() { x.ghost(); }"));
635        let r = resolve_references_to(&s, "ghost");
636        assert_eq!(r[0].resolution, Resolution::Unresolved);
637    }
638
639    // ---- resolution-gated impact closure (#207) ---------------------------
640
641    /// Two unrelated subgraphs joined only by a shared name. `helper` is
642    /// defined in both crates; nothing else is shared. A name-matched closure
643    /// merges them, a resolved one must not.
644    fn bridged() -> InMemoryGraphStore {
645        let mut s = InMemoryGraphStore::new();
646        s.insert(
647            "a.rs",
648            build_rust(
649                "fn leaf_a() {}\n\
650                 fn helper() { leaf_a(); }\n\
651                 fn top_a() { helper(); }",
652            ),
653        );
654        s.insert(
655            "b.rs",
656            build_rust(
657                "fn leaf_b() {}\n\
658                 fn helper() { leaf_b(); }\n\
659                 fn top_b() { helper(); }",
660            ),
661        );
662        s
663    }
664
665    fn names_of(report: &ImpactReport) -> Vec<&str> {
666        report.reached.iter().map(|n| n.name.as_str()).collect()
667    }
668
669    #[test]
670    fn name_matched_impact_merges_the_two_subgraphs() {
671        // The defect, pinned: the heuristic closure from `leaf_a` reaches
672        // b.rs's `top_b`, which cannot call it.
673        let s = bridged();
674        assert!(s.impact("leaf_a").contains(&"top_b".to_string()));
675    }
676
677    #[test]
678    fn resolved_impact_does_not_cross_an_ambiguous_name() {
679        let s = bridged();
680        let report = resolved_impact(&s, "leaf_a", None);
681        assert!(names_of(&report).contains(&"helper"), "{report:?}");
682        assert!(names_of(&report).contains(&"top_a"), "{report:?}");
683        assert!(
684            !names_of(&report).contains(&"top_b"),
685            "must not reach the other subgraph: {report:?}"
686        );
687        assert!(!names_of(&report).contains(&"leaf_b"), "{report:?}");
688    }
689
690    #[test]
691    fn resolved_impact_carries_a_defining_path_for_every_node() {
692        let report = resolved_impact(&bridged(), "leaf_a", None);
693        assert!(!report.reached.is_empty());
694        assert!(
695            report.reached.iter().all(|n| n.path == "a.rs"),
696            "{report:?}"
697        );
698    }
699
700    #[test]
701    fn resolved_impact_excludes_the_seed() {
702        let report = resolved_impact(&bridged(), "leaf_a", None);
703        assert!(!names_of(&report).contains(&"leaf_a"));
704    }
705
706    #[test]
707    fn resolved_impact_counts_ambiguous_edges_it_declined_to_follow() {
708        // `helper` is called from top_a and top_b, each local to its own file,
709        // so those resolve. Add a third file calling `helper` with no local
710        // definition: that edge is genuinely ambiguous and must be reported,
711        // not silently dropped.
712        let mut s = bridged();
713        s.insert("c.rs", build_rust("fn outsider() { helper(); }"));
714        let report = resolved_impact(&s, "leaf_a", None);
715        assert!(
716            report.ambiguous_edges > 0,
717            "an unattributable edge must be reported: {report:?}"
718        );
719        assert!(
720            !names_of(&report).contains(&"outsider"),
721            "and must not be traversed: {report:?}"
722        );
723    }
724
725    #[test]
726    fn resolved_impact_reports_no_ambiguity_when_every_name_is_unique() {
727        let mut s = InMemoryGraphStore::new();
728        s.insert("a.rs", build_rust("fn leaf() {}\nfn mid() { leaf(); }"));
729        let report = resolved_impact(&s, "leaf", None);
730        assert_eq!(report.ambiguous_edges, 0);
731        assert_eq!(names_of(&report), vec!["mid"]);
732        assert!(!report.truncated);
733    }
734
735    #[test]
736    fn resolved_impact_survives_cycles() {
737        let mut s = InMemoryGraphStore::new();
738        s.insert("cyc.rs", build_rust("fn a() { b(); }\nfn b() { a(); }"));
739        let report = resolved_impact(&s, "a", None);
740        assert_eq!(names_of(&report), vec!["b"], "terminates, seed excluded");
741    }
742
743    #[test]
744    fn resolved_impact_respects_max_depth() {
745        let mut s = InMemoryGraphStore::new();
746        s.insert(
747            "a.rs",
748            build_rust("fn l() {}\nfn m() { l(); }\nfn t() { m(); }"),
749        );
750        let one = resolved_impact(&s, "l", Some(1));
751        assert_eq!(names_of(&one), vec!["m"], "one hop only");
752        assert!(one.truncated, "a capped walk must say so");
753
754        // `truncated` means "stopped with frontier left", not "more existed":
755        // at depth 2 the walk has reached `t` but never asked who calls it, so
756        // it cannot claim the set is complete.
757        let two = resolved_impact(&s, "l", Some(2));
758        assert_eq!(names_of(&two), vec!["m", "t"]);
759        assert!(two.truncated, "t was reached but never explored");
760
761        // Only an uncapped walk (or one that exhausts the graph inside the cap)
762        // can honestly report completeness.
763        let deep = resolved_impact(&s, "l", Some(9));
764        assert_eq!(names_of(&deep), vec!["m", "t"]);
765        assert!(!deep.truncated);
766        assert!(!resolved_impact(&s, "l", None).truncated);
767    }
768
769    #[test]
770    fn resolved_impact_on_an_undefined_name_is_empty() {
771        let report = resolved_impact(&bridged(), "ghost", None);
772        assert!(report.reached.is_empty());
773        assert_eq!(report.ambiguous_edges, 0);
774    }
775
776    #[test]
777    fn resolved_impact_walks_every_definition_of_an_ambiguous_seed() {
778        // Seeding on an ambiguous name is legitimate: the caller asked about
779        // "helper", and both are real. Each is walked from its own path.
780        let report = resolved_impact(&bridged(), "helper", None);
781        let mut pairs: Vec<(&str, &str)> = report
782            .reached
783            .iter()
784            .map(|n| (n.name.as_str(), n.path.as_str()))
785            .collect();
786        pairs.sort();
787        assert_eq!(pairs, vec![("top_a", "a.rs"), ("top_b", "b.rs")]);
788    }
789
790    #[test]
791    fn resolved_callers_disambiguates_by_defining_path() {
792        let s = view();
793        // The heuristic callers_of("foo") returns ca, cb (and cd via the ref).
794        assert!(s.callers_of("foo").contains(&"ca".to_string()));
795        assert!(s.callers_of("foo").contains(&"cb".to_string()));
796        // Resolution narrows to callers of the *specific* foo.
797        assert_eq!(
798            resolved_callers_of(&s, "a.rs", "foo"),
799            vec!["ca".to_string()]
800        );
801        assert_eq!(
802            resolved_callers_of(&s, "b.rs", "foo"),
803            vec!["cb".to_string()]
804        );
805    }
806
807    // ---- qualified references (#248) --------------------------------------
808
809    /// Two types, in separate files, each with a `get` method; a third file
810    /// calls one of them by name.
811    fn two_gets() -> InMemoryGraphStore {
812        let mut s = InMemoryGraphStore::new();
813        s.insert(
814            "a.rs",
815            build_rust("struct Alpha; impl Alpha { fn get() {} }"),
816        );
817        s.insert("b.rs", build_rust("struct Beta; impl Beta { fn get() {} }"));
818        s
819    }
820
821    #[test]
822    fn a_qualified_reference_resolves_to_the_matching_owner() {
823        let mut s = two_gets();
824        s.insert("c.rs", build_rust("fn caller() { Beta::get(); }"));
825        let r = resolve_references_to(&s, "get")
826            .into_iter()
827            .find(|r| r.reference.item.from.as_deref() == Some("caller"))
828            .expect("call recorded");
829        assert_eq!(r.resolution, Resolution::Qualified);
830        assert_eq!(r.target.as_deref(), Some("b.rs"));
831    }
832
833    #[test]
834    fn an_unqualified_reference_to_an_overloaded_name_stays_ambiguous() {
835        let mut s = two_gets();
836        s.insert("c.rs", build_rust("fn plain() { get(); }"));
837        let r = resolve_references_to(&s, "get")
838            .into_iter()
839            .find(|r| r.reference.item.from.as_deref() == Some("plain"))
840            .expect("call recorded");
841        assert_eq!(r.resolution, Resolution::Ambiguous);
842        assert_eq!(r.target, None);
843    }
844
845    #[test]
846    fn a_qualified_reference_beats_a_local_definition_of_the_same_name() {
847        // The call says `Beta`, so the free `get` sitting in the same file is
848        // not what it means — the qualifier has to outrank the local rule.
849        let mut s = two_gets();
850        s.insert(
851            "c.rs",
852            build_rust("fn get() {}\nfn caller() { Beta::get(); }"),
853        );
854        let r = resolve_references_to(&s, "get")
855            .into_iter()
856            .find(|r| r.reference.item.from.as_deref() == Some("caller"))
857            .expect("call recorded");
858        assert_eq!(r.resolution, Resolution::Qualified);
859        assert_eq!(r.target.as_deref(), Some("b.rs"));
860    }
861
862    #[test]
863    fn a_qualifier_that_matches_no_owner_falls_back_to_the_existing_ladder() {
864        // `some_module` is a file module, so nothing in the view is *owned* by
865        // it. The qualifier must then be ignored rather than declining an edge
866        // that resolves perfectly well today: this pass only ever narrows.
867        let mut s = InMemoryGraphStore::new();
868        s.insert("a.rs", build_rust("fn helper() {}"));
869        s.insert("b.rs", build_rust("fn caller() { some_module::helper(); }"));
870        let r = resolve_references_to(&s, "helper")
871            .into_iter()
872            .find(|r| r.reference.item.from.as_deref() == Some("caller"))
873            .expect("call recorded");
874        assert_eq!(r.resolution, Resolution::UniqueGlobal);
875        assert_eq!(r.target.as_deref(), Some("a.rs"));
876    }
877
878    #[test]
879    fn resolved_impact_follows_a_qualified_edge_it_used_to_decline() {
880        let mut s = two_gets();
881        s.insert("c.rs", build_rust("fn caller() { Beta::get(); }"));
882        let report = resolved_impact(&s, "get", None);
883        assert!(
884            names_of(&report).contains(&"caller"),
885            "qualified edge must be traversed: {report:?}"
886        );
887        assert_eq!(
888            report.ambiguous_edges, 0,
889            "and must not be counted as declined: {report:?}"
890        );
891    }
892
893    // ---- functions passed as values (#250) --------------------------------
894
895    #[test]
896    fn resolved_impact_counts_a_value_reference_instead_of_traversing_it() {
897        // Passing a function *is* a real dependency, so dropping it silently
898        // would under-report exactly the way #207 and #223 were about not doing.
899        // But a value reference is over-inclusive by construction — a local
900        // named like a function is indistinguishable — so traversing it would
901        // put false edges back into the one tool those tickets cleaned up.
902        // Count it, and let the caller decide whether to go look.
903        let mut s = InMemoryGraphStore::new();
904        s.insert("a.rs", build_rust("fn helper() {}"));
905        s.insert("b.rs", build_rust("fn g() { register(helper); }"));
906
907        let report = resolved_impact(&s, "helper", None);
908        assert!(
909            !names_of(&report).contains(&"g"),
910            "must not be traversed: {report:?}"
911        );
912        assert_eq!(report.value_edges, 1, "must be reported: {report:?}");
913        assert_eq!(report.ambiguous_edges, 0, "{report:?}");
914    }
915
916    #[test]
917    fn a_plain_call_is_not_counted_as_a_value_edge() {
918        let mut s = InMemoryGraphStore::new();
919        s.insert("a.rs", build_rust("fn helper() {}"));
920        s.insert("b.rs", build_rust("fn g() { helper(); }"));
921        let report = resolved_impact(&s, "helper", None);
922        assert!(names_of(&report).contains(&"g"), "{report:?}");
923        assert_eq!(report.value_edges, 0, "{report:?}");
924    }
925
926    // ---- syntactic receiver typing (#251) ---------------------------------
927
928    #[test]
929    fn a_typed_receiver_resolves_to_its_own_type() {
930        // The payoff: `b.get()` used to decline because the receiver's type was
931        // unknown. The body says what `b` is, so it resolves like a written
932        // `Beta::get()` would.
933        let mut s = two_gets();
934        s.insert(
935            "c.rs",
936            build_rust("fn caller() { let b = Beta::new(); b.get(); }"),
937        );
938        let r = resolve_references_to(&s, "get")
939            .into_iter()
940            .find(|r| r.reference.item.from.as_deref() == Some("caller"))
941            .expect("call recorded");
942        assert_eq!(r.resolution, Resolution::Qualified);
943        assert_eq!(r.target.as_deref(), Some("b.rs"));
944        assert_eq!(
945            r.reference.item.kind,
946            RefKind::Method,
947            "still a method call: the shape is what guards #223"
948        );
949    }
950
951    #[test]
952    fn an_untyped_receiver_still_declines_and_is_counted() {
953        let mut s = two_gets();
954        s.insert(
955            "c.rs",
956            build_rust("fn caller() { let b = make(); b.get(); }"),
957        );
958        let r = resolve_references_to(&s, "get")
959            .into_iter()
960            .find(|r| r.reference.item.from.as_deref() == Some("caller"))
961            .expect("call recorded");
962        assert_eq!(r.resolution, Resolution::ReceiverUnknown);
963
964        let report = resolved_impact(&s, "get", None);
965        assert!(
966            report.receiver_unknown_edges > 0,
967            "still reported: {report:?}"
968        );
969    }
970
971    #[test]
972    fn a_receiver_typed_as_something_outside_the_view_falls_through() {
973        // `String::new()` types `s`, but nothing in the view is owned by
974        // `String`, so the qualifier is ignored rather than declining an edge
975        // the older rules resolve — the monotonicity #248 established.
976        let mut s = InMemoryGraphStore::new();
977        s.insert("a.rs", build_rust("fn len() -> usize { 0 }"));
978        s.insert(
979            "b.rs",
980            build_rust("fn caller() { let s = String::new(); s.len(); }"),
981        );
982        let r = resolve_references_to(&s, "len")
983            .into_iter()
984            .find(|r| r.reference.item.from.as_deref() == Some("caller"))
985            .expect("call recorded");
986        // A method call on a receiver typed outside the view is exactly the
987        // #223 case: one same-named project function must not be credited.
988        assert_eq!(r.resolution, Resolution::ReceiverUnknown);
989    }
990
991    // ---- import-aware resolution (#252) -----------------------------------
992
993    /// Two files define `make`; a third calls it, importing one of them.
994    fn two_makes(caller_file: &str, caller_src: &str) -> InMemoryGraphStore {
995        let mut s = InMemoryGraphStore::new();
996        s.insert("src/model.rs", build_rust("fn make() {}"));
997        s.insert("src/other.rs", build_rust("fn make() {}"));
998        s.insert(caller_file, build_rust(caller_src));
999        s
1000    }
1001
1002    #[test]
1003    fn an_import_picks_the_module_it_names() {
1004        let s = two_makes(
1005            "src/app.rs",
1006            "use crate::model::make;\nfn caller() { make(); }",
1007        );
1008        let r = resolve_references_to(&s, "make")
1009            .into_iter()
1010            .find(|r| r.reference.item.from.as_deref() == Some("caller"))
1011            .expect("call recorded");
1012        assert_eq!(r.resolution, Resolution::Imported);
1013        assert_eq!(r.target.as_deref(), Some("src/model.rs"));
1014    }
1015
1016    #[test]
1017    fn without_the_import_the_same_call_stays_ambiguous() {
1018        // The control: nothing else about this view changed.
1019        let s = two_makes("src/app.rs", "fn caller() { make(); }");
1020        let r = resolve_references_to(&s, "make")
1021            .into_iter()
1022            .find(|r| r.reference.item.from.as_deref() == Some("caller"))
1023            .expect("call recorded");
1024        assert_eq!(r.resolution, Resolution::Ambiguous);
1025    }
1026
1027    #[test]
1028    fn an_import_that_does_not_narrow_falls_through() {
1029        // Two `model.rs` under different crates: the import names a module both
1030        // could be, so the honest answer is still ambiguous rather than a coin
1031        // flip. The rule only ever narrows (#248).
1032        let mut s = InMemoryGraphStore::new();
1033        s.insert("a/src/model.rs", build_rust("fn make() {}"));
1034        s.insert("b/src/model.rs", build_rust("fn make() {}"));
1035        s.insert(
1036            "c/src/app.rs",
1037            build_rust("use crate::model::make;\nfn caller() { make(); }"),
1038        );
1039        let r = resolve_references_to(&s, "make")
1040            .into_iter()
1041            .find(|r| r.reference.item.from.as_deref() == Some("caller"))
1042            .expect("call recorded");
1043        assert_eq!(r.resolution, Resolution::Ambiguous);
1044        assert_eq!(r.target, None);
1045    }
1046
1047    #[test]
1048    fn an_import_matches_a_module_directory_through_mod_rs() {
1049        // `model/mod.rs` *is* the module `model`; the filename is not a segment.
1050        let mut s = InMemoryGraphStore::new();
1051        s.insert("src/model/mod.rs", build_rust("fn make() {}"));
1052        s.insert("src/other.rs", build_rust("fn make() {}"));
1053        s.insert(
1054            "src/app.rs",
1055            build_rust("use crate::model::make;\nfn caller() { make(); }"),
1056        );
1057        let r = resolve_references_to(&s, "make")
1058            .into_iter()
1059            .find(|r| r.reference.item.from.as_deref() == Some("caller"))
1060            .expect("call recorded");
1061        assert_eq!(r.target.as_deref(), Some("src/model/mod.rs"));
1062    }
1063
1064    #[test]
1065    fn a_unique_global_still_resolves_without_consulting_imports() {
1066        // Imports are only consulted where the ladder would otherwise give up,
1067        // so nothing that resolves today changes.
1068        let mut s = InMemoryGraphStore::new();
1069        s.insert("src/model.rs", build_rust("fn only() {}"));
1070        s.insert("src/app.rs", build_rust("fn caller() { only(); }"));
1071        let r = resolve_references_to(&s, "only")
1072            .into_iter()
1073            .find(|r| r.reference.item.from.as_deref() == Some("caller"))
1074            .expect("call recorded");
1075        assert_eq!(r.resolution, Resolution::UniqueGlobal);
1076    }
1077
1078    #[test]
1079    fn resolved_impact_follows_an_imported_edge() {
1080        let s = two_makes(
1081            "src/app.rs",
1082            "use crate::model::make;\nfn caller() { make(); }",
1083        );
1084        let report = resolved_impact(&s, "make", None);
1085        assert!(names_of(&report).contains(&"caller"), "{report:?}");
1086    }
1087
1088    #[test]
1089    fn an_import_of_a_crate_root_matches_the_crate_directory() {
1090        // Measured on gonzalo itself: `use gonzalo_cli::list` in
1091        // `crates/gonzalo-cli/src/main.rs` names the *crate*, whose root is
1092        // `crates/gonzalo-cli/src/lib.rs`. Neither `lib` nor `src` is a module,
1093        // and Rust writes the crate name with `_` where the directory has `-`.
1094        let mut s = InMemoryGraphStore::new();
1095        s.insert("crates/gonzalo-cli/src/lib.rs", build_rust("fn list() {}"));
1096        s.insert(
1097            "crates/gonzalo-graph/src/store.rs",
1098            build_rust("fn list() {}"),
1099        );
1100        s.insert(
1101            "crates/gonzalo-cli/src/main.rs",
1102            build_rust("use gonzalo_cli::list;\nfn caller() { list(); }"),
1103        );
1104        let r = resolve_references_to(&s, "list")
1105            .into_iter()
1106            .find(|r| r.reference.item.from.as_deref() == Some("caller"))
1107            .expect("call recorded");
1108        assert_eq!(r.resolution, Resolution::Imported);
1109        assert_eq!(r.target.as_deref(), Some("crates/gonzalo-cli/src/lib.rs"));
1110    }
1111
1112    #[test]
1113    fn a_hyphenated_directory_matches_an_underscored_module() {
1114        let mut s = InMemoryGraphStore::new();
1115        s.insert("my-pkg/src/model.rs", build_rust("fn make() {}"));
1116        s.insert("other/src/model.rs", build_rust("fn make() {}"));
1117        s.insert(
1118            "app.rs",
1119            build_rust("use my_pkg::model::make;\nfn caller() { make(); }"),
1120        );
1121        let r = resolve_references_to(&s, "make")
1122            .into_iter()
1123            .find(|r| r.reference.item.from.as_deref() == Some("caller"))
1124            .expect("call recorded");
1125        assert_eq!(r.target.as_deref(), Some("my-pkg/src/model.rs"));
1126    }
1127
1128    // ---- anchored and local imports (#261) --------------------------------
1129
1130    fn py(src: &str) -> crate::CodeGraph {
1131        crate::build(crate::Language::Python, src)
1132    }
1133
1134    fn java(src: &str) -> crate::CodeGraph {
1135        crate::build(crate::Language::Java, src)
1136    }
1137
1138    fn resolved_from(s: &InMemoryGraphStore, name: &str, caller: &str) -> ResolvedReference {
1139        resolve_references_to(s, name)
1140            .into_iter()
1141            .find(|r| r.reference.item.from.as_deref() == Some(caller))
1142            .expect("call recorded")
1143    }
1144
1145    #[test]
1146    fn a_relative_import_anchors_to_the_referencing_package() {
1147        // The CS-5260 shape: a vendored reference copy beside your own tree, so
1148        // the same module path exists twice and a bare suffix match hits both.
1149        // `from ..DataTypes import Action` means *my* parent package's copy.
1150        let mut s = InMemoryGraphStore::new();
1151        s.insert(
1152            "ReferenceCode/src/cs5260/DataTypes/Action.py",
1153            py("def Action():\n    pass\n"),
1154        );
1155        s.insert(
1156            "WorldTraderSim/src/WorldTraderSim/DataTypes/Action.py",
1157            py("def Action():\n    pass\n"),
1158        );
1159        s.insert(
1160            "ReferenceCode/src/cs5260/Examples/HW2_1.py",
1161            py("from ..DataTypes import Action\ndef run():\n    Action()\n"),
1162        );
1163
1164        let r = resolved_from(&s, "Action", "run");
1165        assert_eq!(r.resolution, Resolution::Imported);
1166        assert_eq!(
1167            r.target.as_deref(),
1168            Some("ReferenceCode/src/cs5260/DataTypes/Action.py")
1169        );
1170    }
1171
1172    #[test]
1173    fn a_single_dot_import_anchors_to_the_files_own_package() {
1174        let mut s = InMemoryGraphStore::new();
1175        s.insert("a/pkg/util.py", py("def helper():\n    pass\n"));
1176        s.insert("b/pkg/util.py", py("def helper():\n    pass\n"));
1177        s.insert(
1178            "a/pkg/main.py",
1179            py("from .util import helper\ndef run():\n    helper()\n"),
1180        );
1181
1182        let r = resolved_from(&s, "helper", "run");
1183        assert_eq!(r.target.as_deref(), Some("a/pkg/util.py"));
1184    }
1185
1186    #[test]
1187    fn an_absolute_import_matching_several_copies_prefers_the_nearest() {
1188        // The Vanderbilt course shape: one package tree per assignment, so an
1189        // absolute import matches every copy. The one in the referencing file's
1190        // own tree is what it means.
1191        let mut s = InMemoryGraphStore::new();
1192        for tree in ["assignment1", "assignment2"] {
1193            s.insert(
1194                &format!("{tree}/src/main/java/edu/vandy/util/Helper.java"),
1195                java("class Helper { static void go() {} }"),
1196            );
1197        }
1198        s.insert(
1199            "assignment2/src/main/java/edu/vandy/app/Main.java",
1200            java("import static edu.vandy.util.Helper.go;\nclass Main { void run() { go(); } }"),
1201        );
1202
1203        let r = resolved_from(&s, "go", "run");
1204        assert_eq!(r.resolution, Resolution::Imported);
1205        assert_eq!(
1206            r.target.as_deref(),
1207            Some("assignment2/src/main/java/edu/vandy/util/Helper.java")
1208        );
1209    }
1210
1211    #[test]
1212    fn two_equally_near_copies_stay_ambiguous() {
1213        // Nothing distinguishes them, so picking one would be a coin flip.
1214        let mut s = InMemoryGraphStore::new();
1215        for tree in ["one", "two"] {
1216            s.insert(
1217                &format!("{tree}/src/main/java/edu/vandy/util/Helper.java"),
1218                java("class Helper { static void go() {} }"),
1219            );
1220        }
1221        s.insert(
1222            "apps/src/main/java/edu/vandy/app/Main.java",
1223            java("import static edu.vandy.util.Helper.go;\nclass Main { void run() { go(); } }"),
1224        );
1225
1226        let r = resolved_from(&s, "go", "run");
1227        assert_eq!(r.resolution, Resolution::Ambiguous);
1228        assert_eq!(r.target, None);
1229    }
1230
1231    #[test]
1232    fn a_relative_import_that_anchors_nowhere_falls_through() {
1233        // More dots than the path has levels: the anchor cannot be computed, so
1234        // the older rules run unchanged rather than the edge being dropped.
1235        let mut s = InMemoryGraphStore::new();
1236        s.insert("util.py", py("def helper():\n    pass\n"));
1237        s.insert(
1238            "main.py",
1239            py("from ...deep import helper\ndef run():\n    helper()\n"),
1240        );
1241        let r = resolved_from(&s, "helper", "run");
1242        assert_eq!(r.resolution, Resolution::UniqueGlobal);
1243    }
1244
1245    // ---- C/C++ includes (#267) ---------------------------------------------
1246
1247    fn c(src: &str) -> crate::CodeGraph {
1248        crate::build(crate::Language::C, src)
1249    }
1250
1251    #[test]
1252    fn an_include_narrows_an_ambiguous_reference() {
1253        // Including a header means calling into what that translation unit
1254        // defines. The include names the file directly (#267).
1255        let mut s = InMemoryGraphStore::new();
1256        s.insert("engine/render/pipeline.c", c("void draw(void) {}"));
1257        s.insert("tools/preview/pipeline.c", c("void draw(void) {}"));
1258        s.insert(
1259            "app/main.c",
1260            c("#include \"engine/render/pipeline.h\"\nvoid run(void) { draw(); }"),
1261        );
1262
1263        let r = resolved_from(&s, "draw", "run");
1264        assert_eq!(r.resolution, Resolution::Imported);
1265        assert_eq!(r.target.as_deref(), Some("engine/render/pipeline.c"));
1266    }
1267
1268    #[test]
1269    fn without_the_include_the_same_c_call_stays_ambiguous() {
1270        let mut s = InMemoryGraphStore::new();
1271        s.insert("engine/render/pipeline.c", c("void draw(void) {}"));
1272        s.insert("tools/preview/pipeline.c", c("void draw(void) {}"));
1273        s.insert("app/main.c", c("void run(void) { draw(); }"));
1274
1275        assert_eq!(
1276            resolved_from(&s, "draw", "run").resolution,
1277            Resolution::Ambiguous
1278        );
1279    }
1280
1281    #[test]
1282    fn an_include_fitting_several_candidates_falls_through() {
1283        // Two files at the same path stem: the include cannot say which.
1284        let mut s = InMemoryGraphStore::new();
1285        s.insert("a/pipeline.c", c("void draw(void) {}"));
1286        s.insert("b/pipeline.c", c("void draw(void) {}"));
1287        s.insert(
1288            "app/main.c",
1289            c("#include \"pipeline.h\"\nvoid run(void) { draw(); }"),
1290        );
1291
1292        assert_eq!(
1293            resolved_from(&s, "draw", "run").resolution,
1294            Resolution::Ambiguous
1295        );
1296    }
1297
1298    #[test]
1299    fn a_system_include_matches_nothing_in_the_view() {
1300        let mut s = InMemoryGraphStore::new();
1301        s.insert("a/x.c", c("void printf_impl(void) {}"));
1302        s.insert("b/x.c", c("void printf_impl(void) {}"));
1303        s.insert(
1304            "app/main.c",
1305            c("#include <stdio.h>\nvoid run(void) { printf_impl(); }"),
1306        );
1307
1308        assert_eq!(
1309            resolved_from(&s, "printf_impl", "run").resolution,
1310            Resolution::Ambiguous,
1311            "stdio is not in the view"
1312        );
1313    }
1314
1315    #[test]
1316    fn a_named_import_still_wins_where_a_language_has_one() {
1317        // Guard: the include path must not displace the named lookup every
1318        // other language depends on.
1319        let s = two_makes(
1320            "src/app.rs",
1321            "use crate::model::make;\nfn caller() { make(); }",
1322        );
1323        assert_eq!(
1324            resolved_from(&s, "make", "caller").target.as_deref(),
1325            Some("src/model.rs")
1326        );
1327    }
1328}