Skip to main content

code_extract/
lib.rs

1//! Deterministic structure extraction for source files.
2//!
3//! Bytes in, facts out. This crate never opens a file, never walks a
4//! directory and never touches a database: everything it knows about the
5//! surrounding tree arrives through the caller's closures. That is what makes
6//! the resulting graph reproducible — two runs over the same working tree
7//! produce byte-identical facts.
8//!
9//! # What comes out
10//!
11//! [`extract`] turns one file's bytes into a [`FileFacts`]: a content hash, a
12//! line count, the symbols defined in the file (qualified in-file, with their
13//! doc line, signature and outgoing calls), the imports as written, and — for
14//! Markdown — headings, mentions and the body text.
15//!
16//! # Resolving what came out
17//!
18//! The extraction step deliberately keeps raw text. [`resolve_import`],
19//! [`resolve_mention`] and [`resolve_call`] turn that raw text into paths and
20//! symbol keys, using caller-supplied lookups:
21//!
22//! * `known(path)` — true when `path` names a file in the working tree.
23//! * `files_in(dir)` — the working-tree paths of the files directly inside
24//!   `dir`, empty when the directory does not exist.
25//! * `by_basename(name)` — every working-tree path whose file name is `name`.
26//!
27//! All paths, in and out, are working-tree-relative files that use `/`
28//! separators. No result is ever a directory.
29//!
30//! [`SymbolIndex`] keys are `<file path>#<qualified symbol name>`; the caller
31//! builds the index in that shape so [`resolve_call`] can prefer a definition
32//! in the calling file or its directory. [`resolve_call`] also takes the
33//! calling file's resolved imports, which is what lets a call reach a
34//! definition in another crate whose name is not unique repository-wide — and
35//! is the only thing that can reach one at all for a call written on a
36//! receiver, which never falls back to repository-wide uniqueness.
37//!
38//! A method is stored qualified — `Store.flush` — and written on a receiver —
39//! `store.flush()` — so the index files it under the bare name too. Those
40//! entries are read only where the receiver identifies the type, because
41//! stripping a receiver says which method is wanted and nothing about what it
42//! belongs to: see [`resolve_call`].
43//!
44//! # Determinism
45//!
46//! Every returned collection is sorted and deduplicated, and nothing depends
47//! on hash-map iteration order. Output is capped so a pathological file cannot
48//! blow up the store: see [`MAX_FILE_BYTES`], [`MAX_BODY_BYTES`],
49//! [`MAX_TEXT_CHARS`] and [`MAX_CALLS`].
50
51mod docs;
52mod hash;
53mod lang;
54
55pub use docs::resolve_mention;
56
57use std::collections::{BTreeMap, BTreeSet};
58
59/// Files larger than this are reduced to hash, language and line count.
60pub const MAX_FILE_BYTES: usize = 1024 * 1024;
61/// Upper bound on the stored Markdown body, in bytes.
62pub const MAX_BODY_BYTES: usize = 65_536;
63/// Upper bound, in characters, on a symbol signature or doc line.
64pub const MAX_TEXT_CHARS: usize = 200;
65/// Upper bound on the calls recorded for one symbol.
66pub const MAX_CALLS: usize = 256;
67/// Wall-clock budget for parsing one file.
68pub const PARSE_BUDGET_MS: u64 = 500;
69
70/// How many leading bytes are inspected when deciding whether a file is binary.
71const BINARY_PROBE_BYTES: usize = 8 * 1024;
72
73/// The languages this crate can read.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
75pub enum Lang {
76    Rust,
77    Python,
78    TypeScript,
79    Tsx,
80    JavaScript,
81    Go,
82    Markdown,
83    Other,
84}
85
86impl Lang {
87    /// A stable lowercase name, suitable for storing as a node property.
88    #[must_use]
89    pub const fn as_str(self) -> &'static str {
90        match self {
91            Lang::Rust => "rust",
92            Lang::Python => "python",
93            Lang::TypeScript => "typescript",
94            Lang::Tsx => "tsx",
95            Lang::JavaScript => "javascript",
96            Lang::Go => "go",
97            Lang::Markdown => "markdown",
98            Lang::Other => "other",
99        }
100    }
101}
102
103/// Classify a path by its extension. Unknown extensions are [`Lang::Other`],
104/// which yields hash-only facts.
105#[must_use]
106pub fn lang_of(path: &str) -> Lang {
107    let name = file_name(path);
108    let ext = match name.rsplit_once('.') {
109        Some((stem, ext)) if !stem.is_empty() => ext,
110        _ => return Lang::Other,
111    };
112    let lower = ext.to_ascii_lowercase();
113    match lower.as_str() {
114        "rs" => Lang::Rust,
115        "py" | "pyi" => Lang::Python,
116        "ts" | "mts" | "cts" => Lang::TypeScript,
117        "tsx" => Lang::Tsx,
118        "js" | "mjs" | "cjs" | "jsx" => Lang::JavaScript,
119        "go" => Lang::Go,
120        "md" | "markdown" => Lang::Markdown,
121        _ => Lang::Other,
122    }
123}
124
125/// One definition found in a file.
126#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
127pub struct SymbolFact {
128    /// Qualified within the file: `Type.method`, `mod::fn`, `Receiver.method`.
129    pub name: String,
130    /// One of `function`, `method`, `class`, `struct`, `enum`, `trait`,
131    /// `interface`, `type`, `const`, `module`.
132    pub kind: &'static str,
133    /// 1-based first line of the definition.
134    pub line_start: u32,
135    /// 1-based last line of the definition.
136    pub line_end: u32,
137    /// The declaration line, whitespace-collapsed, at most
138    /// [`MAX_TEXT_CHARS`] characters.
139    pub signature: String,
140    /// The first line of the doc comment, at most [`MAX_TEXT_CHARS`]
141    /// characters. Empty when the definition is undocumented.
142    pub doc: String,
143    /// One entry per call site. Sorted, deduplicated, at most [`MAX_CALLS`]
144    /// entries.
145    pub calls: Vec<CallFact>,
146}
147
148/// One call as written, and enough about how it was written to resolve it.
149#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
150pub struct CallFact {
151    /// The callee as written: `flush`, `Store::flush`, `self.flush`.
152    pub callee: String,
153    /// 1-based line of the call site.
154    pub line: u32,
155    /// The call was written in *method* position — a receiver, a dot, a name.
156    /// See [`written_as_method`].
157    pub method: bool,
158}
159
160impl CallFact {
161    /// A call written as a bare name or a path: `flush`, `a::b::flush`.
162    #[must_use]
163    pub fn plain(callee: &str, line: u32) -> Self {
164        CallFact {
165            callee: callee.to_string(),
166            line,
167            method: false,
168        }
169    }
170
171    /// A call written on a receiver: `self.flush`, `store.flush`.
172    #[must_use]
173    pub fn method(callee: &str, line: u32) -> Self {
174        CallFact {
175            callee: callee.to_string(),
176            line,
177            method: true,
178        }
179    }
180}
181
182/// Whether a callee as written names a receiver rather than a path.
183///
184/// Every language here reports the callee as the source wrote it, and in all of
185/// them a `.` separates a value or a module from the name being called —
186/// `self.flush`, `store.flush`, `os.path.join`, `pkg.Func`. A path uses `::`
187/// (Rust) or nothing at all, neither of which contains a dot. So one rule
188/// covers every grammar, and it is the same rule
189/// [`resolve_call`]'s candidate splitting already uses to find the last segment.
190#[must_use]
191pub fn written_as_method(callee: &str) -> bool {
192    callee.contains('.')
193}
194
195/// One import as written in the source.
196#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
197pub struct ImportFact {
198    /// The module path or specifier as written, normalised per language:
199    /// a Rust `use` path with any group expanded (`a::b::c`) or a module
200    /// declaration (`mod x`); a Python dotted module (`a.b`, `.sibling`); a
201    /// TypeScript or JavaScript specifier (`./util`); a Go import path.
202    pub raw: String,
203    /// 1-based line of the import.
204    pub line: u32,
205}
206
207/// Everything one file contributes to the graph.
208#[derive(Clone, Debug, PartialEq, Eq)]
209pub struct FileFacts {
210    pub lang: Lang,
211    /// First 16 bytes of the BLAKE3 digest, as 32 hex characters.
212    pub hash: String,
213    pub lines: u32,
214    /// Sorted by `(line_start, name)`.
215    pub symbols: Vec<SymbolFact>,
216    /// Sorted by `(line, raw)`, deduplicated.
217    pub imports: Vec<ImportFact>,
218    /// Markdown headings in document order.
219    pub headings: Vec<String>,
220    /// Markdown mention tokens as written. Sorted, deduplicated.
221    pub mentions: Vec<String>,
222    /// Markdown body text, at most [`MAX_BODY_BYTES`] bytes, cut on a
223    /// character boundary. `None` for every other language.
224    pub body: Option<String>,
225}
226
227impl FileFacts {
228    fn bare(lang: Lang, hash: String, lines: u32) -> Self {
229        FileFacts {
230            lang,
231            hash,
232            lines,
233            symbols: Vec::new(),
234            imports: Vec::new(),
235            headings: Vec::new(),
236            mentions: Vec::new(),
237            body: None,
238        }
239    }
240}
241
242/// Extract the facts for one file.
243///
244/// Never panics and never fails: anything it cannot read — a binary file, a
245/// file over [`MAX_FILE_BYTES`], an unknown extension, a parse that overruns
246/// [`PARSE_BUDGET_MS`] — degrades to hash, language and line count.
247#[must_use]
248pub fn extract(path: &str, bytes: &[u8]) -> FileFacts {
249    let lang = lang_of(path);
250    let mut facts = FileFacts::bare(lang, hash::hex32(bytes), hash::count_lines(bytes));
251    if bytes.len() > MAX_FILE_BYTES || lang == Lang::Other {
252        return facts;
253    }
254    let Some(text) = decode(bytes) else {
255        return facts;
256    };
257    if lang == Lang::Markdown {
258        docs::extract(&mut facts, text);
259    } else if let Some((symbols, imports)) = lang::extract(lang, text) {
260        facts.symbols = symbols;
261        facts.imports = imports;
262    }
263    facts
264}
265
266/// Decode as UTF-8, rejecting anything that looks binary.
267fn decode(bytes: &[u8]) -> Option<&str> {
268    let probe = &bytes[..bytes.len().min(BINARY_PROBE_BYTES)];
269    if probe.contains(&0) {
270        return None;
271    }
272    std::str::from_utf8(bytes).ok()
273}
274
275/// Resolve one import to the working-tree paths it names.
276///
277/// Returns an empty vector for anything outside the working tree — the
278/// standard library, a registry dependency, a bare npm specifier. The result
279/// is sorted and deduplicated.
280///
281/// # Rules
282///
283/// **Rust.** `mod x` resolves against the declaring file's module directory
284/// (`lib.rs`, `main.rs` and `mod.rs` own their own directory; every other
285/// file owns a directory named after its stem) to `<dir>/x.rs` or
286/// `<dir>/x/mod.rs`. `crate::a::b` resolves under the nearest ancestor
287/// directory that has both a `Cargo.toml` and a `src/`, trying the longest
288/// module prefix first: `src/a/b.rs`, `src/a/b/mod.rs`, `src/a.rs`,
289/// `src/a/mod.rs`. `self::` resolves under the declaring file's module
290/// directory and `super::` one directory further up per `super`. A leading
291/// segment that names a sibling directory holding its own `Cargo.toml` (with
292/// `_` and `-` treated as interchangeable) resolves to that package's
293/// `src/lib.rs`; the directories searched are the crate root's parent and
294/// every ancestor of the declaring file, so no layout convention is assumed.
295///
296/// **Python.** `a.b` and `.b` resolve to `<dir>/a/b.py` or
297/// `<dir>/a/b/__init__.py`, first relative to the importing file's directory
298/// and then relative to the working-tree root. Leading dots walk upwards.
299/// Modules whose first segment is in the standard library are skipped.
300///
301/// **TypeScript and JavaScript.** Only relative specifiers resolve. The
302/// specifier is tried as written, then with each source extension appended,
303/// then as a directory with an `index.*`. A specifier ending in `.js` also
304/// tries `.ts` and `.tsx`, which is how TypeScript sources refer to their own
305/// compiled output.
306///
307/// **Go.** Imports name a package, not a file, so the result is every
308/// non-test `.go` file directly inside the matching package directory —
309/// `_test.go` files are excluded, since a test file is never what an import
310/// reaches. The longest suffix of the import path that names a directory
311/// holding Go sources wins, which strips the module prefix without needing to
312/// read `go.mod`. This is the one rule that needs `files_in`; every other
313/// language resolves through `known` alone.
314///
315/// **Markdown.** Markdown has no imports; use [`resolve_mention`].
316#[must_use]
317pub fn resolve_import(
318    lang: Lang,
319    from_path: &str,
320    raw: &str,
321    known: &dyn Fn(&str) -> bool,
322    files_in: &dyn Fn(&str) -> Vec<String>,
323) -> Vec<String> {
324    let from = normalize(from_path);
325    let raw = raw.trim();
326    let mut out = match lang {
327        Lang::Rust => lang::rust::resolve_import(&from, raw, known),
328        Lang::Python => lang::python::resolve_import(&from, raw, known),
329        Lang::TypeScript | Lang::Tsx | Lang::JavaScript => {
330            lang::typescript::resolve_import(&from, raw, known)
331        }
332        Lang::Go => lang::go::resolve_import(&from, raw, files_in),
333        Lang::Markdown | Lang::Other => Vec::new(),
334    };
335    out.sort();
336    out.dedup();
337    out
338}
339
340/// Symbol keys grouped by symbol name, built by the caller.
341///
342/// A key is `<file path>#<qualified symbol name>` — everything before the
343/// last `#` is taken as the defining file. [`resolve_call`] uses that to
344/// prefer nearby definitions, so a caller that builds keys in some other
345/// shape loses the same-file and same-directory tiers and falls back to
346/// repo-wide uniqueness.
347#[derive(Clone, Debug, Default)]
348pub struct SymbolIndex {
349    by_name: BTreeMap<String, Vec<String>>,
350    by_method: BTreeMap<String, Vec<String>>,
351}
352
353impl SymbolIndex {
354    #[must_use]
355    pub fn new() -> Self {
356        SymbolIndex::default()
357    }
358
359    /// Record that `name` is defined by the symbol at `key`. Inserting the
360    /// same pair twice is a no-op, and the stored keys stay sorted, so the
361    /// index does not depend on insertion order.
362    ///
363    /// A method — a name qualified `Type.method` — is filed twice: under the
364    /// name as written, and under the bare `method`. Source almost never
365    /// writes the qualified form. `store.flush()` says `flush` after the
366    /// receiver is stripped, and without the second entry no call on a
367    /// receiver could ever reach a method definition. The two sets are kept
368    /// apart because they carry different weight: see [`resolve_call`], which
369    /// consults the bare entries only where the tier itself is evidence.
370    pub fn insert(&mut self, name: &str, key: &str) {
371        insert_sorted(self.by_name.entry(name.to_string()).or_default(), key);
372        if let Some(bare) = method_name(name) {
373            insert_sorted(self.by_method.entry(bare.to_string()).or_default(), key);
374        }
375    }
376
377    /// Number of distinct names in the index.
378    #[must_use]
379    pub fn len(&self) -> usize {
380        self.by_name.len()
381    }
382
383    #[must_use]
384    pub fn is_empty(&self) -> bool {
385        self.by_name.is_empty()
386    }
387
388    fn keys_for(&self, name: &str) -> &[String] {
389        self.by_name.get(name).map_or(&[], Vec::as_slice)
390    }
391
392    /// Keys of the methods whose bare name is `name`, whatever type they
393    /// belong to.
394    fn methods_for(&self, name: &str) -> &[String] {
395        self.by_method.get(name).map_or(&[], Vec::as_slice)
396    }
397}
398
399fn insert_sorted(slot: &mut Vec<String>, key: &str) {
400    if let Err(at) = slot.binary_search_by(|held| held.as_str().cmp(key)) {
401        slot.insert(at, key.to_string());
402    }
403}
404
405/// The bare name of a qualified method: `flush` in `Store.flush`, `None` for a
406/// name that is not written as a method.
407fn method_name(name: &str) -> Option<&str> {
408    let (_, bare) = name.rsplit_once('.')?;
409    (!bare.is_empty()).then_some(bare)
410}
411
412/// The type a method belongs to: `Store` in `Store.flush`, and in
413/// `mod::Store.flush`.
414fn receiver_type(name: &str) -> Option<&str> {
415    let (owner, _) = name.rsplit_once('.')?;
416    let owner = owner.rsplit(['.', ':']).next().unwrap_or(owner);
417    (!owner.is_empty()).then_some(owner)
418}
419
420/// What a call was written on: `store` in `store.flush`, and `symbols` — not
421/// `self` — in `self.symbols.len`. The segment immediately before the method
422/// is the thing whose method is being called.
423fn receiver_of(callee: &str) -> Option<&str> {
424    receiver_type(callee.trim())
425}
426
427/// Whether the receiver a call was written on identifies `symbol`'s type.
428///
429/// This is the guard that keeps the bare-name entries from turning every
430/// `.len()` in the repository into an edge. Stripping the receiver tells you
431/// *which* method is wanted and nothing about *what* it belongs to, and on a
432/// real codebase the reachable `Type.len` is almost never the `Vec` the source
433/// meant. Two receivers say what the type is:
434///
435/// * `self` — and `Self`, `this`, `cls`, which are the same idea in the five
436///   languages here — is the type being implemented, so a
437///   candidate in the calling file is it. That is where `impl` blocks put
438///   their methods, and it is the case that matters: `self.flush()` is how
439///   most method calls inside a type are written.
440/// * a variable named after its type — `store: Store`, `symbol_index:
441///   SymbolIndex` — which every language here spells consistently enough to be
442///   evidence once case and `_` are collapsed.
443///
444/// Anything else is a variable whose type the graph does not know, and the
445/// truthful answer for it is no edge. That costs real calls — `rs.len()` on a
446/// `ResultSet` gets nothing — and the trade is deliberate: this resolver's
447/// whole design is that a wrong edge is worse than a missing one.
448fn receiver_names(receiver: &str, symbol: &str, in_calling_file: bool) -> bool {
449    if matches!(receiver, "self" | "Self" | "this" | "cls") {
450        return in_calling_file;
451    }
452    receiver_type(symbol).is_some_and(|ty| squash(receiver) == squash(ty))
453}
454
455/// Lowercased with `_` dropped, so `symbol_index` and `SymbolIndex` meet.
456fn squash(name: &str) -> String {
457    name.chars()
458        .filter(|c| *c != '_')
459        .flat_map(char::to_lowercase)
460        .collect()
461}
462
463/// Every name [`SymbolIndex`] files a definition called `name` under.
464///
465/// The mirror of [`call_lookup_names`], and the other half of what a caller
466/// building a *narrowed* index needs: a `Store.flush` has to be kept when
467/// something looks up the bare `flush`, or a method call loses the very
468/// definition the bare entry exists to reach.
469#[must_use]
470pub fn indexed_under(name: &str) -> Vec<String> {
471    let mut out = vec![name.to_string()];
472    if let Some(bare) = method_name(name) {
473        if bare != name {
474            out.push(bare.to_string());
475        }
476    }
477    out
478}
479
480/// What the calling file can see, beyond the symbol index itself.
481///
482/// Both halves are built by the caller from the working tree it already walked,
483/// so [`resolve_call`] stays a pure function of its inputs.
484#[derive(Clone, Copy, Debug)]
485pub struct CallScope<'a> {
486    /// The calling file's resolved import targets, as [`resolve_import`]
487    /// returned them and the `File` node stores them.
488    pub imports: &'a [String],
489    /// Every name a path call may lead with: package, directory and module
490    /// names the tree holds, each also under the `-`/`_` spelling a package
491    /// path uses. A leading segment that is not in here and is not a symbol
492    /// names a dependency, and a call into a dependency resolves to nothing.
493    pub roots: &'a BTreeSet<String>,
494}
495
496/// Resolve a callee written in `from_file` to the key of the symbol it names.
497///
498/// The callee is tried as written and then as its last segment, so
499/// `self.flush`, `this.flush` and `Store::flush` all reach `flush`. Within
500/// each attempt the search narrows outwards, and the first tier that yields
501/// exactly one definition wins:
502///
503/// 1. a definition in the same file;
504/// 2. a definition in the same directory;
505/// 3. a definition in a file `from_file` imports;
506/// 4. a single definition anywhere in the tree.
507///
508/// Anything still ambiguous resolves to `None` — a wrong edge is worse than a
509/// missing one. The two tiers that search by name rather than by a stated
510/// relationship — the directory and the whole tree — also require the
511/// definition to be in the calling file's language: see [`same_language`].
512///
513/// # Why imports matter
514///
515/// Tier 4 can only answer when the name is unique across the whole repository,
516/// so two crates that each define a `render` left every call to either one
517/// unresolved, and a call that crossed a crate boundary was the common case
518/// for that. `imports` is the caller file's already-resolved import targets —
519/// the same list [`resolve_import`] produced and the `File` node stores — and a
520/// definition living in one of them is the one the source actually named. It
521/// sits below the directory tiers because a file that both imports a module and
522/// defines the name itself means the local one.
523///
524/// # Why a method call stops at tier 3
525///
526/// Tier 4 is uniqueness, not reachability: it claims any name defined exactly
527/// once anywhere in the tree, whether or not the calling file could name it.
528/// For a bare or path call that is a fair guess, because the source wrote a
529/// name it expected to be in scope. For a call written on a receiver it is not:
530/// `.collect()`, `.take()`, `.get()` and `.ok()` belong to types the graph has
531/// never seen, and the tier happily bound them to whatever single test helper
532/// happened to share the name.
533///
534/// So a [`CallFact::method`] call reaches tier 4 only on the callee **as
535/// written**, never on the bare last segment it falls back to. `Store.flush`
536/// matching a symbol qualified `Store.flush` names the receiver's type, which
537/// is how a static call reads in Python, TypeScript and JavaScript, and is
538/// evidence rather than a guess. `v.collect` falling back to `collect` is the
539/// guess, and it gets no edge — which is the truthful answer for a call into a
540/// dependency the graph does not hold. Tier 3 is narrowed the same way: an
541/// imported match for a method call must itself be a method.
542///
543/// # Calls into a dependency
544///
545/// Before any of that, a path call whose leading segment names nothing in the
546/// tree resolves to nothing at all — see [`path_reaches_the_tree`].
547/// `std::mem::take` is not a call to whatever single `take` this repository
548/// defines.
549#[must_use]
550pub fn resolve_call(
551    from_file: &str,
552    call: &CallFact,
553    index: &SymbolIndex,
554    scope: &CallScope<'_>,
555) -> Option<String> {
556    if !path_reaches_the_tree(&call.callee, index, scope.roots) {
557        return None;
558    }
559    let from = normalize(from_file);
560    let from_dir = parent_dir(&from);
561    for name in callee_candidates(&call.callee) {
562        // A method call reaches the repository-wide tier only on a candidate
563        // that still carries its receiver. `Store.flush` matching a symbol
564        // qualified `Store.flush` names the receiver's type and is evidence;
565        // the bare `flush` it falls back to is a guess, and that guess is what
566        // bound `.collect()` to an unrelated helper. Testing the candidate
567        // rather than its position matters because a call recovered from a
568        // macro's token tree arrives as the bare segment already.
569        let repo_wide = !call.method || written_as_method(&name);
570        let keys = index.keys_for(&name);
571        // The bare-name entries of the index, consulted only for the exact
572        // case they exist for: a call written on a receiver, fallen back to
573        // the bare segment. `store.flush()` reaches `Store.flush` this way and
574        // no other, because no source ever writes the qualified form.
575        let methods: &[String] = match call.method && !written_as_method(&name) {
576            true => index.methods_for(&name),
577            false => &[],
578        };
579        if keys.is_empty() && methods.is_empty() {
580            continue;
581        }
582        let pick = |filter: &dyn Fn(&str, &str) -> bool| -> Option<String> {
583            let mut hit = None;
584            for key in keys {
585                if filter(key_file(key), key_symbol(key)) {
586                    if hit.is_some() {
587                        return None;
588                    }
589                    hit = Some(key.clone());
590                }
591            }
592            hit
593        };
594        // The same, over the bare-name entries, with one difference: the
595        // receiver has to identify the type — see `receiver_names`. Two
596        // candidates still passing that at one tier means two types whose
597        // names collide once case and `_` are dropped, and there is nothing
598        // left to tell them apart, so neither gets the edge.
599        let receiver = receiver_of(&call.callee);
600        let pick_method = |filter: &dyn Fn(&str) -> bool| -> Option<String> {
601            let mut hit = None;
602            for key in methods {
603                let named = receiver
604                    .is_some_and(|r| receiver_names(r, key_symbol(key), key_file(key) == from));
605                if filter(key_file(key)) && named {
606                    if hit.is_some() {
607                        return None;
608                    }
609                    hit = Some(key.clone());
610                }
611            }
612            hit
613        };
614        let same_dir = |file: &str| parent_dir(file) == from_dir && same_language(&from, file);
615        let imported = |file: &str| scope.imports.iter().any(|i| i == file);
616
617        if let Some(key) = pick(&|file, _| file == from).or_else(|| pick_method(&|f| f == from)) {
618            return Some(key);
619        }
620        if let Some(key) = pick(&|file, _| same_dir(file)).or_else(|| pick_method(&same_dir)) {
621            return Some(key);
622        }
623        // An import brings a *file* into scope, not a type. For a method call
624        // that is not enough: the receiver's type is unknown, and a free
625        // function in an imported file that happens to share the method's name
626        // is not the thing being called. `repo.join(id)` is `Path::join`, but
627        // the calling file imports a module that defines a `join`. So an
628        // imported match has to be a method — a symbol qualified `Type.name`,
629        // which is how every extractor here names one. The bare-name entries
630        // are all methods by construction, and are held to the calling file's
631        // language as well, which the exact-name imports tier does not need to
632        // be: an import is a stated relationship, a bare method name is not.
633        if let Some(key) =
634            pick(&|file, symbol| imported(file) && (!call.method || symbol.contains('.')))
635                .or_else(|| pick_method(&|f| imported(f) && same_language(&from, f)))
636        {
637            return Some(key);
638        }
639        if repo_wide {
640            if let Some(key) = pick(&|file, _| same_language(&from, file)) {
641                return Some(key);
642            }
643        }
644    }
645    None
646}
647
648/// Whether two files are written in the same language.
649///
650/// A call site and a definition that share nothing but a name are not the same
651/// function. `str(x)` in Python is a builtin; that this repository also holds a
652/// Rust `fn str` is a coincidence, and the tiers that search by name alone —
653/// the same directory, and the whole tree — happily turned it into an edge from
654/// a `.py` file to a `.rs` one.
655///
656/// TypeScript, TSX and JavaScript are one family, because a `.ts` module really
657/// does call into a `.tsx` one and neither is a different language for this
658/// purpose. Every other pairing must match exactly. Two files of unknown type
659/// count as the same language and contribute no symbols either way.
660fn same_language(a: &str, b: &str) -> bool {
661    family(lang_of(a)) == family(lang_of(b))
662}
663
664/// The language a file resolves calls as, collapsing the ECMAScript variants.
665const fn family(lang: Lang) -> Lang {
666    match lang {
667        Lang::TypeScript | Lang::Tsx | Lang::JavaScript => Lang::TypeScript,
668        other => other,
669    }
670}
671
672/// Whether a `::`-separated path call names anything the working tree holds.
673///
674/// `std::mem::take`, `serde_json::from_str` and `Vec::new` are calls into
675/// dependencies. The graph has no node for any of them, and the tiers below
676/// would happily bind the last segment to whatever single `take`, `from_str` or
677/// `new` the repository happened to define — on this repository that was mostly
678/// test helpers. A path is worth resolving only when its first segment names
679/// something here:
680///
681/// * `crate`, `self`, `super` and `Self`, which are always the current tree;
682/// * a package, directory or module name the caller listed in `roots`;
683/// * a symbol — `Store::flush` leads with a type, not a module.
684///
685/// A callee with no `::` is not a path and passes untouched, which leaves bare
686/// calls and every dotted form to the tiers and the method rule.
687fn path_reaches_the_tree(callee: &str, index: &SymbolIndex, roots: &BTreeSet<String>) -> bool {
688    let Some((first, _)) = callee.split_once("::") else {
689        return true;
690    };
691    let first = first.trim();
692    matches!(first, "crate" | "self" | "super" | "Self")
693        || roots.contains(first)
694        || !index.keys_for(first).is_empty()
695}
696
697/// Every name [`resolve_call`] may look `callee` up under in the index.
698///
699/// The resolver reaches a [`SymbolIndex`] by name and by nothing else: it tries
700/// the callee as written, then its last segment, and before either it asks
701/// whether a `::` path's leading segment names a symbol here
702/// ([`path_reaches_the_tree`]). Those are the only three forms.
703///
704/// That is what lets a caller build a *narrowed* index — one holding only the
705/// definitions a particular pass could need — without changing a single edge.
706/// An index holding every definition of exactly these names answers every
707/// lookup a whole-tree index would, tier for tier, the repository-wide tier
708/// included: that tier turns on a name being defined exactly once, and the
709/// narrowed index still holds every definition of the names it is asked about.
710/// Ask for less than this and resolution silently changes.
711#[must_use]
712pub fn call_lookup_names(callee: &str) -> Vec<String> {
713    let mut out = callee_candidates(callee);
714    if let Some((first, _)) = callee.trim().split_once("::") {
715        let first = first.trim().to_string();
716        if !first.is_empty() && !out.contains(&first) {
717            out.push(first);
718        }
719    }
720    out
721}
722
723/// The callee as written, then its last `.`- or `::`-separated segment.
724fn callee_candidates(callee: &str) -> Vec<String> {
725    let callee = callee.trim();
726    let mut out = vec![callee.to_string()];
727    let after_dot = callee.rfind('.').map(|at| at + 1);
728    let after_colon = callee.rfind("::").map(|at| at + 2);
729    if let Some(cut) = after_dot.into_iter().chain(after_colon).max() {
730        if let Some(tail) = callee.get(cut..) {
731            if !tail.is_empty() && tail != callee {
732                out.push(tail.to_string());
733            }
734        }
735    }
736    out
737}
738
739/// The file half of a symbol key.
740fn key_file(key: &str) -> &str {
741    key.rsplit_once('#').map_or(key, |(file, _)| file)
742}
743
744/// The symbol half of a symbol key: the name, qualified within its file.
745fn key_symbol(key: &str) -> &str {
746    key.rsplit_once('#').map_or("", |(_, name)| name)
747}
748
749// ── path helpers ────────────────────────────────────────────────────────────
750// Working-tree-relative, `/`-separated, no symlink or filesystem access.
751
752/// Everything before the last `/`, or `""` for a top-level path.
753pub(crate) fn parent_dir(path: &str) -> &str {
754    path.rsplit_once('/').map_or("", |(dir, _)| dir)
755}
756
757/// Everything after the last `/`.
758pub(crate) fn file_name(path: &str) -> &str {
759    path.rsplit_once('/').map_or(path, |(_, name)| name)
760}
761
762/// The file name without its final extension.
763pub(crate) fn file_stem(path: &str) -> &str {
764    let name = file_name(path);
765    match name.rsplit_once('.') {
766        Some((stem, _)) if !stem.is_empty() => stem,
767        _ => name,
768    }
769}
770
771/// Collapse `.`, `..` and empty segments. Leading `..` segments survive,
772/// which keeps a path that escapes the working tree from silently becoming a
773/// path inside it.
774pub(crate) fn normalize(path: &str) -> String {
775    let mut stack: Vec<&str> = Vec::new();
776    for seg in path.split('/') {
777        match seg {
778            "" | "." => {}
779            ".." => match stack.last() {
780                Some(&last) if last != ".." => {
781                    stack.pop();
782                }
783                _ => stack.push(".."),
784            },
785            other => stack.push(other),
786        }
787    }
788    stack.join("/")
789}
790
791/// Join `rest` onto directory `base` and normalise.
792pub(crate) fn join(base: &str, rest: &str) -> String {
793    if base.is_empty() {
794        normalize(rest)
795    } else {
796        normalize(&format!("{base}/{rest}"))
797    }
798}
799
800/// `dir` and every ancestor of it, longest first, ending with `""`.
801pub(crate) fn ancestors(dir: &str) -> Vec<String> {
802    let mut out = Vec::new();
803    let mut cur = dir;
804    loop {
805        out.push(cur.to_string());
806        if cur.is_empty() {
807            break;
808        }
809        cur = parent_dir(cur);
810    }
811    out
812}
813
814/// The first candidate `known` accepts, as a one-element vector.
815pub(crate) fn first_known(candidates: &[String], known: &dyn Fn(&str) -> bool) -> Vec<String> {
816    for cand in candidates {
817        if known(cand) {
818            return vec![cand.clone()];
819        }
820    }
821    Vec::new()
822}
823
824// ── text helpers ────────────────────────────────────────────────────────────
825
826/// Truncate to at most `max` characters.
827pub(crate) fn truncate_chars(text: &str, max: usize) -> String {
828    match text.char_indices().nth(max) {
829        Some((at, _)) => text[..at].to_string(),
830        None => text.to_string(),
831    }
832}
833
834/// Truncate to at most `max` bytes, backing up to a character boundary.
835pub(crate) fn truncate_bytes(text: &str, max: usize) -> String {
836    if text.len() <= max {
837        return text.to_string();
838    }
839    let mut end = max;
840    while end > 0 && !text.is_char_boundary(end) {
841        end -= 1;
842    }
843    text[..end].to_string()
844}
845
846/// Collapse every run of whitespace to a single space and trim.
847pub(crate) fn squeeze(text: &str) -> String {
848    let mut out = String::with_capacity(text.len());
849    let mut space = false;
850    for ch in text.chars() {
851        if ch.is_whitespace() {
852            space = !out.is_empty();
853        } else {
854            if space {
855                out.push(' ');
856            }
857            space = false;
858            out.push(ch);
859        }
860    }
861    out
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867
868    #[test]
869    fn extensions_map_to_languages() {
870        assert_eq!(lang_of("a/b.rs"), Lang::Rust);
871        assert_eq!(lang_of("a/b.py"), Lang::Python);
872        assert_eq!(lang_of("a/b.ts"), Lang::TypeScript);
873        assert_eq!(lang_of("a/b.tsx"), Lang::Tsx);
874        assert_eq!(lang_of("a/b.mjs"), Lang::JavaScript);
875        assert_eq!(lang_of("a/b.go"), Lang::Go);
876        assert_eq!(lang_of("a/b.md"), Lang::Markdown);
877        assert_eq!(lang_of("a/b.bin"), Lang::Other);
878        assert_eq!(lang_of("Makefile"), Lang::Other);
879        assert_eq!(lang_of(".gitignore"), Lang::Other);
880    }
881
882    #[test]
883    fn paths_normalise_without_touching_disk() {
884        assert_eq!(normalize("./a//b/../c.rs"), "a/c.rs");
885        assert_eq!(normalize("../a/b.rs"), "../a/b.rs");
886        assert_eq!(join("src/net", "../util.rs"), "src/util.rs");
887        assert_eq!(parent_dir("a/b/c.rs"), "a/b");
888        assert_eq!(parent_dir("c.rs"), "");
889        assert_eq!(file_stem("a/b/mod.rs"), "mod");
890        assert_eq!(ancestors("a/b"), vec!["a/b", "a", ""]);
891    }
892
893    #[test]
894    fn text_helpers_stay_on_character_boundaries() {
895        assert_eq!(truncate_chars("héllo", 2), "hé");
896        assert_eq!(truncate_bytes("héllo", 2), "h");
897        assert_eq!(squeeze("  pub  fn\n  run() "), "pub fn run()");
898    }
899}