Skip to main content

comindly_core/
resolve.rs

1//! Comindly resolve — bind per-file facts to definitions, within and across repos.
2//!
3//! A reference path (`self.api.get`, `events.publish`, `kit.formatDate`) is resolved segment by
4//! segment through imports, re-exports, modules, classes (incl. bases) and typed variables.
5//! Emits `Imports`, `Calls`, `Instantiates`, `Inherits` and `Implements` edges.
6
7use std::cell::RefCell;
8use std::collections::{HashMap, HashSet};
9
10use crate::facts::{FileFacts, Import, ImportItem, RefKind, TypeSource};
11use crate::model::{Edge, EdgeKind, Language, Symbol, SymbolKind};
12use crate::packages::strip_ts_ext;
13
14mod contracts;
15pub use contracts::{contract_id, http_path, CONTRACTS};
16
17/// Name → (line, SCIP symbol) occurrences within one file.
18type ScipOccs<'a> = HashMap<&'a str, Vec<(u32, &'a str)>>;
19
20/// Guard against re-export / alias cycles.
21const MAX_DEPTH: u32 = 12;
22
23#[derive(Debug, Default)]
24pub struct Resolved {
25    pub edges: Vec<Edge>,
26    /// Contract nodes (routes, queues, buckets, tables) to add to the symbol set.
27    pub symbols: Vec<Symbol>,
28    pub stats: ResolveStats,
29    /// Top-level module → count of import items that didn't resolve.
30    pub unresolved_modules: HashMap<String, usize>,
31    /// Top-level module → a few `path:line module item` examples.
32    pub unresolved_examples: HashMap<String, Vec<String>>,
33}
34
35#[derive(Debug, Default, Clone, Copy)]
36pub struct ResolveStats {
37    pub resolved_imports: usize,
38    /// Imports whose target is outside the corpus (mostly third-party).
39    pub unresolved_imports: usize,
40    /// Calls and instantiations bound through scopes, imports and types.
41    pub resolved_calls: usize,
42    pub unresolved_calls: usize,
43    /// `Inherits` + `Implements`.
44    pub type_edges: usize,
45    /// References answered by SCIP rather than tree-sitter resolution.
46    pub scip_answers: usize,
47    /// Participant → contract edges (serves, consumes, publishes, …).
48    pub contract_edges: usize,
49    pub cross_repo_edges: usize,
50}
51
52#[derive(Debug, Clone, PartialEq)]
53enum Value {
54    File(String),
55    /// A module or package by name (a Python namespace package may have no file).
56    Module {
57        fam: Family,
58        name: String,
59        repo: String,
60    },
61    Sym(String),
62    Instance(String),
63}
64
65/// Resolve the merged corpus. Input edges pass through; new edges come from `facts`.
66pub fn resolve(symbols: &[Symbol], edges: &[Edge], facts: &[FileFacts]) -> Resolved {
67    let b = Binder::new(symbols, edges, facts);
68    let mut out = Resolved {
69        edges: edges.to_vec(),
70        ..Default::default()
71    };
72    let mut new_edges: Vec<(String, String, EdgeKind, f32)> = Vec::new();
73
74    // Bases first: method lookup walks them.
75    for f in facts {
76        for r in &f.refs {
77            if !matches!(r.kind, RefKind::Inherits | RefKind::Implements) {
78                continue;
79            }
80            let scope = b.parent.get(&r.from).cloned().unwrap_or(f.file_id.clone());
81            let name = r.path.last().map_or("", String::as_str);
82            let target = match b.scip(f, name, r.line) {
83                Some(t) => {
84                    out.stats.scip_answers += 1;
85                    Some(t)
86                }
87                None => match b.resolve_path(&r.path, &scope, f, 0) {
88                    Some(Value::Sym(t)) => Some(t),
89                    _ => None,
90                },
91            };
92            let Some(t) = target else {
93                continue;
94            };
95            if !b.is_type(&t) {
96                continue;
97            }
98            b.bases
99                .borrow_mut()
100                .entry(r.from.clone())
101                .or_default()
102                .push(t.clone());
103            let kind = if r.kind == RefKind::Inherits {
104                EdgeKind::Inherits
105            } else {
106                EdgeKind::Implements
107            };
108            new_edges.push((r.from.clone(), t, kind, 0.9));
109            out.stats.type_edges += 1;
110        }
111    }
112
113    for f in facts {
114        for imp in &f.imports {
115            let base = b.import_target(f, imp);
116            for item in &imp.items {
117                let target = match item {
118                    ImportItem::Name { name, .. } => match b.scip(f, name, imp.line) {
119                        Some(t) => {
120                            out.stats.scip_answers += 1;
121                            Some(Value::Sym(t))
122                        }
123                        None => base.as_ref().and_then(|v| b.member(v, name, 0)),
124                    },
125                    ImportItem::Module { .. } | ImportItem::All => base.clone(),
126                };
127                match target.and_then(|v| b.node_of(&v)) {
128                    Some(t) if t != f.file_id => {
129                        new_edges.push((f.file_id.clone(), t, EdgeKind::Imports, 0.95));
130                        out.stats.resolved_imports += 1;
131                    }
132                    Some(_) => {}
133                    None => {
134                        out.stats.unresolved_imports += 1;
135                        let top = top_module(f, imp);
136                        let ex = out.unresolved_examples.entry(top.clone()).or_default();
137                        if ex.len() < 3 {
138                            ex.push(format!("{}:{} {} {:?}", f.path, imp.line, imp.module, item));
139                        }
140                        *out.unresolved_modules.entry(top).or_default() += 1;
141                    }
142                }
143            }
144        }
145
146        for r in &f.refs {
147            if !matches!(r.kind, RefKind::Call | RefKind::New) {
148                continue;
149            }
150            let name = r.path.last().map_or("", String::as_str);
151            let target = match b.scip(f, name, r.line) {
152                Some(t) => {
153                    out.stats.scip_answers += 1;
154                    Some(t)
155                }
156                None => match b.resolve_path(&r.path, &r.from, f, 0) {
157                    Some(Value::Sym(t)) => Some(t),
158                    _ => None,
159                },
160            };
161            let Some(t) = target else {
162                out.stats.unresolved_calls += 1;
163                continue;
164            };
165            let kind = if b.is_type(&t) {
166                EdgeKind::Instantiates
167            } else if b.is_callable(&t) {
168                EdgeKind::Calls
169            } else {
170                out.stats.unresolved_calls += 1;
171                continue;
172            };
173            out.stats.resolved_calls += 1;
174            new_edges.push((r.from.clone(), t, kind, 0.9));
175        }
176    }
177
178    let calls: Vec<(String, String)> = new_edges
179        .iter()
180        .filter(|(_, _, k, _)| *k == EdgeKind::Calls)
181        .map(|(s, d, _, _)| (s.clone(), d.clone()))
182        .collect();
183    let linked = contracts::link(&b, facts, &calls);
184    for (src, dst, kind) in linked.edges {
185        // Sources are code symbols, or contracts themselves (a queue subscribed to a topic).
186        let Some(src_id) = b.sym(&src).map(|s| s.id.clone()).or_else(|| {
187            linked
188                .symbols
189                .iter()
190                .find(|c| c.id.render() == src)
191                .map(|c| c.id.clone())
192        }) else {
193            continue;
194        };
195        let dst_id = linked
196            .symbols
197            .iter()
198            .find(|c| c.id.render() == dst)
199            .map(|c| c.id.clone());
200        if let Some(d) = dst_id {
201            out.stats.contract_edges += 1;
202            out.edges.push(Edge {
203                src: src_id,
204                dst: d,
205                kind,
206                confidence: 0.9,
207                cross_repo: false,
208            });
209        }
210    }
211    out.symbols = linked.symbols;
212
213    let mut seen: HashSet<(String, String, EdgeKind)> = out
214        .edges
215        .iter()
216        .map(|e| (e.src.render(), e.dst.render(), e.kind.clone()))
217        .collect();
218    for (src, dst, kind, confidence) in new_edges {
219        let (Some(s), Some(d)) = (b.sym(&src), b.sym(&dst)) else {
220            continue;
221        };
222        if !seen.insert((src, dst, kind.clone())) {
223            continue;
224        }
225        let cross_repo = s.repo != d.repo;
226        if cross_repo {
227            out.stats.cross_repo_edges += 1;
228        }
229        out.edges.push(Edge {
230            src: s.id.clone(),
231            dst: d.id.clone(),
232            kind,
233            confidence,
234            cross_repo,
235        });
236    }
237    out
238}
239
240/// Languages sharing module semantics.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
242enum Family {
243    Py,
244    Js,
245    Go,
246    Jvm,
247    Rust,
248}
249
250impl Family {
251    /// Separator between a module and its submodules, where submodules exist.
252    fn sub_sep(self) -> Option<&'static str> {
253        match self {
254            Family::Py => Some("."),
255            Family::Rust => Some("::"),
256            _ => None,
257        }
258    }
259}
260
261fn family(lang: &Language) -> Option<Family> {
262    match lang {
263        Language::Python => Some(Family::Py),
264        Language::TypeScript | Language::JavaScript => Some(Family::Js),
265        Language::Go => Some(Family::Go),
266        Language::Java | Language::Kotlin => Some(Family::Jvm),
267        Language::Rust => Some(Family::Rust),
268        _ => None,
269    }
270}
271
272struct Binder<'a> {
273    syms: HashMap<String, &'a Symbol>,
274    parent: HashMap<String, String>,
275    /// Parent id → child name → child id.
276    members: HashMap<String, HashMap<&'a str, String>>,
277    facts: HashMap<&'a str, &'a FileFacts>,
278    /// Module / package name → files (one per Python module, many per Go package).
279    modules: HashMap<(Family, &'a str), Vec<&'a FileFacts>>,
280    /// Every prefix of a Python/Rust module path (namespace packages, `mod` parents).
281    packages: HashSet<(Family, String)>,
282    by_path: HashMap<(&'a str, &'a str), &'a FileFacts>,
283    types: HashMap<(&'a str, &'a str), &'a TypeSource>,
284    /// Statically known values of module/class names (object literals act as namespaces).
285    values: HashMap<(&'a str, &'a str), &'a crate::facts::ValueExpr>,
286    bases: RefCell<HashMap<String, Vec<String>>>,
287    /// SCIP symbol → our id, for every definition SCIP mapped.
288    scip_defs: HashMap<&'a str, &'a str>,
289    /// File id → name → (line, SCIP symbol) of reference occurrences.
290    scip_refs: HashMap<&'a str, ScipOccs<'a>>,
291}
292
293impl<'a> Binder<'a> {
294    fn new(symbols: &'a [Symbol], edges: &[Edge], facts: &'a [FileFacts]) -> Self {
295        let syms: HashMap<String, &Symbol> = symbols.iter().map(|s| (s.id.render(), s)).collect();
296        let mut parent = HashMap::new();
297        let mut members: HashMap<String, HashMap<&str, String>> = HashMap::new();
298        for e in edges.iter().filter(|e| e.kind == EdgeKind::Contains) {
299            let (p, c) = (e.src.render(), e.dst.render());
300            if let Some(child) = syms.get(&c) {
301                members
302                    .entry(p.clone())
303                    .or_default()
304                    .insert(child.name.as_str(), c.clone());
305            }
306            parent.insert(c, p);
307        }
308        let mut b = Binder {
309            syms,
310            parent,
311            members,
312            facts: HashMap::new(),
313            modules: HashMap::new(),
314            packages: HashSet::new(),
315            by_path: HashMap::new(),
316            types: HashMap::new(),
317            values: HashMap::new(),
318            bases: RefCell::new(HashMap::new()),
319            scip_defs: HashMap::new(),
320            scip_refs: HashMap::new(),
321        };
322        for f in facts {
323            b.facts.insert(f.file_id.as_str(), f);
324            b.by_path.insert((f.repo.as_str(), f.path.as_str()), f);
325            let Some(fam) = family(&f.language) else {
326                continue;
327            };
328            for m in &f.modules {
329                b.modules.entry((fam, m.as_str())).or_default().push(f);
330                if let Some(sep) = fam.sub_sep() {
331                    let mut prefix = String::new();
332                    for part in m.split(sep) {
333                        if !prefix.is_empty() {
334                            prefix.push_str(sep);
335                        }
336                        prefix.push_str(part);
337                        b.packages.insert((fam, prefix.clone()));
338                    }
339                }
340            }
341            for t in &f.types {
342                b.types
343                    .entry((t.scope.as_str(), t.name.as_str()))
344                    .or_insert(&t.source);
345            }
346            for v in &f.values {
347                b.values
348                    .insert((v.scope.as_str(), v.name.as_str()), &v.value);
349            }
350            if let Some(sf) = &f.scip {
351                for (sym, id) in &sf.defs {
352                    b.scip_defs.insert(sym.as_str(), id.as_str());
353                }
354                let by_name = b.scip_refs.entry(f.file_id.as_str()).or_default();
355                for r in &sf.refs {
356                    by_name
357                        .entry(r.name.as_str())
358                        .or_default()
359                        .push((r.line, r.symbol.as_str()));
360                }
361            }
362        }
363        b
364    }
365
366    /// SCIP's exact target for `name` referenced at `line`, when it maps into the corpus.
367    /// Unknown targets defer to tree-sitter resolution (other repos may lack SCIP).
368    fn scip(&self, f: &FileFacts, name: &str, line: u32) -> Option<String> {
369        let occs = self.scip_refs.get(f.file_id.as_str())?.get(name)?;
370        // A call spanning lines puts the callee name at or just after the statement start.
371        let (_, sym) = occs
372            .iter()
373            .filter(|(l, _)| *l >= line && *l <= line + 3)
374            .min_by_key(|(l, _)| *l)?;
375        self.scip_defs.get(sym).map(|id| id.to_string())
376    }
377
378    fn sym(&self, id: &str) -> Option<&'a Symbol> {
379        self.syms.get(id).copied()
380    }
381
382    fn is_type(&self, id: &str) -> bool {
383        self.sym(id).is_some_and(|s| {
384            matches!(
385                s.kind,
386                SymbolKind::Class
387                    | SymbolKind::Interface
388                    | SymbolKind::Struct
389                    | SymbolKind::Trait
390                    | SymbolKind::Enum
391            )
392        })
393    }
394
395    fn is_callable(&self, id: &str) -> bool {
396        self.sym(id)
397            .is_some_and(|s| matches!(s.kind, SymbolKind::Function | SymbolKind::Method))
398    }
399
400    /// Files of a module: those in the importing repo, else those of the only repo defining it.
401    fn module_files(&self, fam: Family, name: &str, repo: &str) -> Vec<&'a FileFacts> {
402        let Some(cands) = self.modules.get(&(fam, name)) else {
403            return Vec::new();
404        };
405        let local: Vec<_> = cands.iter().filter(|f| f.repo == repo).copied().collect();
406        if !local.is_empty() {
407            return local;
408        }
409        let first = &cands[0].repo;
410        if cands.iter().all(|f| &f.repo == first) {
411            cands.clone()
412        } else {
413            Vec::new()
414        }
415    }
416
417    /// What an import statement's module refers to.
418    fn import_target(&self, f: &FileFacts, imp: &Import) -> Option<Value> {
419        let module = |fam, name: String| Value::Module {
420            fam,
421            name,
422            repo: f.repo.clone(),
423        };
424        match family(&f.language)? {
425            Family::Py => {
426                if imp.level == 0 {
427                    return Some(module(Family::Py, imp.module.clone()));
428                }
429                let own = f.modules.first()?;
430                let mut parts: Vec<&str> = own.split('.').collect();
431                let is_pkg = f.path.ends_with("__init__.py") || f.path.ends_with("__init__.pyi");
432                let drop = imp.level as usize - usize::from(is_pkg);
433                parts.truncate(parts.len().checked_sub(drop)?);
434                if !imp.module.is_empty() {
435                    parts.push(&imp.module);
436                }
437                Some(module(Family::Py, parts.join(".")))
438            }
439            fam @ (Family::Go | Family::Jvm) => Some(module(fam, imp.module.clone())),
440            // Uniform paths: `use store::X` may name a child module rather than a crate.
441            Family::Rust => {
442                let head = imp.module.split("::").next().unwrap_or_default();
443                let local = f
444                    .modules
445                    .first()
446                    .filter(|own| {
447                        self.packages
448                            .contains(&(Family::Rust, format!("{own}::{head}")))
449                    })
450                    .map(|own| format!("{own}::{}", imp.module));
451                Some(module(
452                    Family::Rust,
453                    local.unwrap_or_else(|| imp.module.clone()),
454                ))
455            }
456            Family::Js if imp.file.is_some() => self
457                .by_path
458                .get(&(f.repo.as_str(), imp.file.as_deref()?))
459                .map(|t| Value::File(t.file_id.clone())),
460            Family::Js if imp.module.starts_with('.') => {
461                let dir = f.path.rsplit_once('/').map_or("", |(d, _)| d);
462                let base = normalize(dir, &imp.module)?;
463                let stem = strip_ts_ext(&base);
464                [
465                    ".ts",
466                    ".tsx",
467                    ".d.ts",
468                    ".js",
469                    ".jsx",
470                    ".mjs",
471                    "/index.ts",
472                    "/index.tsx",
473                    "/index.js",
474                ]
475                .iter()
476                .find_map(|e| {
477                    let path = format!("{stem}{e}");
478                    self.by_path
479                        .get(&(f.repo.as_str(), path.as_str()))
480                        .map(|t| Value::File(t.file_id.clone()))
481                })
482            }
483            Family::Js => {
484                let files = self.module_files(Family::Js, &imp.module, &f.repo);
485                (files.len() == 1).then(|| Value::File(files[0].file_id.clone()))
486            }
487        }
488    }
489
490    /// The graph node a value denotes (file or symbol id); a package maps to its main file.
491    fn node_of(&self, v: &Value) -> Option<String> {
492        match v {
493            Value::File(id) | Value::Sym(id) => Some(id.clone()),
494            Value::Module { fam, name, repo } => {
495                let files = self.module_files(*fam, name, repo);
496                let last = name.rsplit(['/', '.']).next().unwrap_or(name);
497                files
498                    .iter()
499                    .find(|f| {
500                        f.path.rsplit('/').next().and_then(|n| n.split('.').next()) == Some(last)
501                    })
502                    .or_else(|| files.iter().min_by_key(|f| &f.path))
503                    .map(|f| f.file_id.clone())
504            }
505            Value::Instance(_) => None,
506        }
507    }
508
509    fn member(&self, v: &Value, name: &str, depth: u32) -> Option<Value> {
510        if depth > MAX_DEPTH {
511            return None;
512        }
513        match v {
514            Value::File(f) => self.module_member(f, name, true, depth + 1),
515            Value::Module { fam, name: m, repo } => {
516                // Python/JS/Rust modules re-export what they import; Go/JVM packages don't.
517                let reexports = matches!(fam, Family::Py | Family::Js | Family::Rust);
518                let files = self.module_files(*fam, m, repo);
519                if let Some(x) = files
520                    .iter()
521                    .find_map(|f| self.module_member(&f.file_id, name, reexports, depth + 1))
522                {
523                    return Some(x);
524                }
525                // `a.b.Outer.Inner` (JVM) / `krate::Enum::Variant` (Rust): walk through the type.
526                let sep = match fam {
527                    Family::Jvm => Some("."),
528                    Family::Rust => Some("::"),
529                    _ => None,
530                };
531                if let (Some(sep), true) = (sep, files.is_empty()) {
532                    let (pkg, outer) = m.rsplit_once(sep)?;
533                    let outer = self.member(
534                        &Value::Module {
535                            fam: *fam,
536                            name: pkg.to_string(),
537                            repo: repo.clone(),
538                        },
539                        outer,
540                        depth + 1,
541                    )?;
542                    return self.member(&outer, name, depth + 1);
543                }
544                let sub = format!("{m}{}{name}", fam.sub_sep()?);
545                self.packages
546                    .contains(&(*fam, sub.clone()))
547                    .then(|| Value::Module {
548                        fam: *fam,
549                        name: sub,
550                        repo: repo.clone(),
551                    })
552            }
553            Value::Sym(id) if self.is_type(id) => self.class_member(id, name, depth + 1),
554            // A module variable: an object literal's field, else a member of its inferred type.
555            Value::Sym(id) => {
556                let var = self
557                    .sym(id)
558                    .filter(|s| matches!(s.kind, SymbolKind::Variable | SymbolKind::Constant))?;
559                let scope = self.parent.get(id)?;
560                if let Some(crate::facts::ValueExpr::Record(fields)) =
561                    self.values.get(&(scope.as_str(), var.name.as_str()))
562                {
563                    if let Some((_, v)) = fields.iter().find(|(k, _)| k == name) {
564                        return match v {
565                            crate::facts::ValueExpr::Name(p) => {
566                                let f = self.file_facts_of(scope)?;
567                                self.resolve_path(p, scope, f, depth + 1)
568                            }
569                            _ => None,
570                        };
571                    }
572                }
573                let src = self.types.get(&(scope.as_str(), var.name.as_str()))?;
574                let inst = self.type_of(src, scope, depth + 1)?;
575                self.member(&inst, name, depth + 1)
576            }
577            Value::Instance(c) => {
578                for recv in ["self", "this"] {
579                    let attr = format!("{recv}.{name}");
580                    if let Some(src) = self.types.get(&(c.as_str(), attr.as_str())) {
581                        if let Some(v) = self.type_of(src, c, depth + 1) {
582                            return Some(v);
583                        }
584                        // A field holding a function reference (`readonly fetch = _fetch`).
585                        if let TypeSource::Alias { name: n, scope: s } = src {
586                            let f = self.file_facts_of(s)?;
587                            if let Some(v @ Value::Sym(_)) = self.resolve_name(n, s, f, depth + 1) {
588                                return Some(v);
589                            }
590                        }
591                        return None;
592                    }
593                }
594                self.class_member(c, name, depth + 1)
595            }
596        }
597    }
598
599    /// A name in a file's namespace: its definitions, then (optionally) what its imports bind.
600    fn module_member(&self, file: &str, name: &str, imports: bool, depth: u32) -> Option<Value> {
601        if depth > MAX_DEPTH {
602            return None;
603        }
604        if let Some(id) = self.members.get(file).and_then(|m| m.get(name)) {
605            return Some(Value::Sym(id.clone()));
606        }
607        if !imports {
608            return None;
609        }
610        let f = self.facts.get(file)?;
611        for imp in f.imports.iter().rev() {
612            for item in &imp.items {
613                let hit = match item {
614                    ImportItem::Name { name: n, alias } => {
615                        if alias.as_deref().unwrap_or(n) != name {
616                            continue;
617                        }
618                        self.import_target(f, imp)
619                            .and_then(|t| self.member(&t, n, depth + 1))
620                    }
621                    ImportItem::Module { alias: Some(a) } if a == name => {
622                        self.import_target(f, imp)
623                    }
624                    // `import a.b` binds `a`.
625                    ImportItem::Module { alias: None }
626                        if f.language == Language::Python
627                            && imp.module.split('.').next() == Some(name) =>
628                    {
629                        Some(Value::Module {
630                            fam: Family::Py,
631                            name: name.to_string(),
632                            repo: f.repo.clone(),
633                        })
634                    }
635                    ImportItem::All => self
636                        .import_target(f, imp)
637                        .and_then(|t| self.member(&t, name, depth + 1)),
638                    _ => continue,
639                };
640                if hit.is_some() {
641                    return hit;
642                }
643            }
644        }
645        None
646    }
647
648    fn class_member(&self, class: &str, name: &str, depth: u32) -> Option<Value> {
649        if depth > MAX_DEPTH {
650            return None;
651        }
652        if let Some(id) = self.members.get(class).and_then(|m| m.get(name)) {
653            return Some(Value::Sym(id.clone()));
654        }
655        let bases = self.bases.borrow().get(class).cloned().unwrap_or_default();
656        bases
657            .iter()
658            .find_map(|b| self.class_member(b, name, depth + 1))
659    }
660
661    /// The instance type a hint denotes, read in `scope`.
662    fn type_of(&self, src: &TypeSource, scope: &str, depth: u32) -> Option<Value> {
663        if depth > MAX_DEPTH {
664            return None;
665        }
666        match src {
667            TypeSource::Annotation(p) => {
668                let f = self.file_facts_of(scope)?;
669                match self.resolve_path(p, scope, f, depth + 1)? {
670                    Value::Sym(t) if self.is_type(&t) => Some(Value::Instance(t)),
671                    _ => None,
672                }
673            }
674            // `x = Cls(...)` is an instance; `x = make()` takes make's declared return type.
675            TypeSource::Construct(p) => {
676                let f = self.file_facts_of(scope)?;
677                match self.resolve_path(p, scope, f, depth + 1)? {
678                    Value::Sym(t) if self.is_type(&t) => Some(Value::Instance(t)),
679                    Value::Sym(t) if self.is_callable(&t) => {
680                        let ret = self.types.get(&(t.as_str(), crate::parse::RETURN))?;
681                        self.type_of(ret, &t, depth + 1)
682                    }
683                    _ => None,
684                }
685            }
686            TypeSource::Alias { name, scope: s } => {
687                let f = self.file_facts_of(s)?;
688                match self.resolve_name(name, s, f, depth + 1)? {
689                    v @ Value::Instance(_) => Some(v),
690                    _ => None,
691                }
692            }
693        }
694    }
695
696    /// The file a scope is written in (a Go method's parent type may live elsewhere).
697    fn file_facts_of(&self, id: &str) -> Option<&'a FileFacts> {
698        if let Some(f) = self.facts.get(id) {
699            return Some(f);
700        }
701        let s = self.sym(id)?;
702        self.by_path
703            .get(&(s.repo.0.as_str(), s.file_path.as_str()))
704            .copied()
705    }
706
707    /// Scopes from `scope` outward to (and including) the file.
708    fn scope_chain(&self, scope: &str) -> Vec<String> {
709        let mut out = vec![scope.to_string()];
710        let mut cur = scope;
711        while let Some(p) = self.parent.get(cur) {
712            out.push(p.clone());
713            cur = p;
714        }
715        out
716    }
717
718    fn enclosing_class(&self, scope: &str) -> Option<String> {
719        self.scope_chain(scope)
720            .into_iter()
721            .find(|s| self.is_type(s))
722    }
723
724    fn resolve_name(&self, head: &str, scope: &str, f: &FileFacts, depth: u32) -> Option<Value> {
725        if head == "self" || head == "this" {
726            return self.enclosing_class(scope).map(Value::Instance);
727        }
728        if f.language == Language::Rust {
729            if let Some(v) = self.rust_head(head, scope, f) {
730                return Some(v);
731            }
732        }
733        for s in self.scope_chain(scope) {
734            if let Some(src) = self.types.get(&(s.as_str(), head)) {
735                if let Some(v) = self.type_of(src, &s, depth + 1) {
736                    return Some(v);
737                }
738                // A local of unknown type shadows outer names; a module variable is its symbol.
739                if s != f.file_id {
740                    return None;
741                }
742            }
743            // Functions nested in an enclosing callable (closures).
744            if self.is_callable(&s) {
745                if let Some(id) = self.members.get(&s).and_then(|m| m.get(head)) {
746                    return Some(Value::Sym(id.clone()));
747                }
748            }
749        }
750        let fam = family(&f.language)?;
751        // JVM: members and fields of the enclosing class (and its bases) need no `this.`.
752        if fam == Family::Jvm {
753            if let Some(c) = self.enclosing_class(scope) {
754                if let Some(v) = self.member(&Value::Instance(c), head, depth + 1) {
755                    return Some(v);
756                }
757            }
758        }
759        if let Some(v) = self.module_member(&f.file_id, head, true, depth + 1) {
760            return Some(v);
761        }
762        // Rust: a child module (`mod util;`) or another crate named directly.
763        if fam == Family::Rust {
764            let own = f.modules.first()?;
765            for cand in [format!("{own}::{head}"), head.to_string()] {
766                if self.packages.contains(&(Family::Rust, cand.clone())) {
767                    return Some(Value::Module {
768                        fam,
769                        name: cand,
770                        repo: f.repo.clone(),
771                    });
772                }
773            }
774            return None;
775        }
776        // Go/JVM: the whole package shares one namespace across files.
777        if !matches!(fam, Family::Go | Family::Jvm) {
778            return None;
779        }
780        let own = f.modules.first()?;
781        self.module_files(fam, own, &f.repo)
782            .iter()
783            .filter(|s| s.file_id != f.file_id)
784            .find_map(|s| self.module_member(&s.file_id, head, false, depth + 1))
785    }
786
787    /// Rust path heads: `Self` (the impl type), `crate`, `<self>` and `super` (modules).
788    fn rust_head(&self, head: &str, scope: &str, f: &FileFacts) -> Option<Value> {
789        let own = f.modules.first()?;
790        let name = match head {
791            "Self" => return self.enclosing_class(scope).map(Value::Sym),
792            "crate" => own.split("::").next()?.to_string(),
793            "<self>" => own.clone(),
794            "super" => own.rsplit_once("::")?.0.to_string(),
795            _ => return None,
796        };
797        Some(Value::Module {
798            fam: Family::Rust,
799            name,
800            repo: f.repo.clone(),
801        })
802    }
803
804    fn resolve_path(
805        &self,
806        path: &[String],
807        scope: &str,
808        f: &FileFacts,
809        depth: u32,
810    ) -> Option<Value> {
811        let (head, rest) = path.split_first()?;
812        let mut v = self.resolve_name(head, scope, f, depth)?;
813        for seg in rest {
814            v = self.member(&v, seg, depth)?;
815        }
816        Some(v)
817    }
818}
819
820/// The package an import names: `fastapi`, `@scope/pkg`, or `./rel` for relative imports.
821fn top_module(f: &FileFacts, imp: &Import) -> String {
822    if imp.level > 0 || imp.module.starts_with('.') {
823        return format!("(relative in {})", f.repo);
824    }
825    if matches!(f.language, Language::Java | Language::Kotlin) {
826        return imp
827            .module
828            .splitn(4, '.')
829            .take(3)
830            .collect::<Vec<_>>()
831            .join(".");
832    }
833    // Go: host/org/repo identifies the module.
834    if f.language == Language::Go
835        && imp
836            .module
837            .split('/')
838            .next()
839            .is_some_and(|h| h.contains('.'))
840    {
841        return imp
842            .module
843            .splitn(4, '/')
844            .take(3)
845            .collect::<Vec<_>>()
846            .join("/");
847    }
848    let mut parts = imp.module.split(['.', '/']);
849    let first = parts.next().unwrap_or_default();
850    match (first.starts_with('@'), parts.next()) {
851        (true, Some(second)) => format!("{first}/{second}"),
852        _ => first.to_string(),
853    }
854}
855
856/// Join a relative module specifier onto a directory, resolving `.` and `..`.
857fn normalize(dir: &str, spec: &str) -> Option<String> {
858    let mut parts: Vec<&str> = dir.split('/').filter(|p| !p.is_empty()).collect();
859    for seg in spec.split('/') {
860        match seg {
861            "" | "." => {}
862            ".." => {
863                parts.pop()?;
864            }
865            s => parts.push(s),
866        }
867    }
868    Some(parts.join("/"))
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    #[test]
876    fn normalize_relative_specifiers() {
877        assert_eq!(normalize("src/a", "./b").as_deref(), Some("src/a/b"));
878        assert_eq!(normalize("src/a", "../c/d").as_deref(), Some("src/c/d"));
879        assert_eq!(normalize("", "./x").as_deref(), Some("x"));
880        assert_eq!(normalize("", "../x"), None);
881    }
882}