Skip to main content

fxrank_lang_python/detect/
refs.rs

1//! Call-reference extraction: walks a function body for every outgoing call
2//! (free-function calls and method calls) and emits a language-neutral
3//! `CallSiteRef` per site.  Unlike `calls.rs`, this module records *every*
4//! named callee rather than filtering to known-effectful paths — it is the
5//! graph-edge source for cross-file propagation.
6//!
7//! # Python `qualified` rule
8//! A reference is a qualified outward reference iff its leading name resolves to
9//! an import in the file's `Imports` table (`os.getcwd` where `os` is imported;
10//! `from sub import run; run()`). Bare locals and `self.`/receiver methods (root
11//! not imported) → `qualified = false`.
12
13use fxrank_core::record::{CallSiteRef, RefKind};
14use libcst_native::Call;
15
16use super::{
17    EffectSink,
18    expr::{leftmost_name, render_expr},
19    walk_own_body,
20};
21use crate::functions::FnUnit;
22use crate::imports::Imports;
23use crate::source::{SpanIndex, anchor_of_subslice};
24
25/// Extract all outgoing call references from `unit`'s own body.
26///
27/// For each call node encountered:
28/// - `base = render_expr(&call.func)` (e.g. `"os.getcwd"`, `"self.method"`,
29///   `"foo"`). Calls where `render_expr` returns `None` are skipped.
30/// - `root = base.split('.').next()` ; `module = imports.resolve(root)`.
31/// - `qualified = module.is_some()` — the Python rule.
32/// - `kind = RefKind::Method` if `base.contains('.')` AND `module.is_none()`
33///   (a receiver attribute/method like `self.foo`/`x.bar`); else `RefKind::Free`.
34/// - `line`/`col` from the `leftmost_name` anchor via the span.
35/// - `resolved_target` via the unified expand-split-resolve rule (see brief §5.3):
36///   Step A: expand `base` to a full dotted path using `imports.resolve(root)`,
37///   distinguishing the `import a.b.c` form (R is a segment-boundary prefix of
38///   base → use base as-is) from the `from m import n` form (R is not a prefix
39///   → replace root with R, keep trailing members). Step B: split full into
40///   `target_module` + `name`. Step C: resolve `target_module` via the map
41///   (relative if `imports.relative_level(root)` is Some, else absolute) and
42///   emit `[..key, name]` on a hit; else `None`.
43pub fn extract(
44    unit: &FnUnit,
45    imports: &Imports,
46    span: &SpanIndex,
47    referencing_module: &[String],
48    referencing_is_package: bool,
49    module_map: &crate::module_map::PyModuleMap,
50) -> Vec<CallSiteRef> {
51    let mut sink = RefSink {
52        imports,
53        span,
54        referencing_module,
55        referencing_is_package,
56        module_map,
57        refs: Vec::new(),
58    };
59    walk_own_body(unit, &mut sink);
60    sink.refs
61}
62
63struct RefSink<'a> {
64    imports: &'a Imports,
65    span: &'a SpanIndex<'a>,
66    referencing_module: &'a [String],
67    referencing_is_package: bool,
68    module_map: &'a crate::module_map::PyModuleMap,
69    refs: Vec<CallSiteRef>,
70}
71
72impl EffectSink for RefSink<'_> {
73    fn on_call(&mut self, call: &Call) {
74        let Some(rendered) = render_expr(&call.func) else {
75            return;
76        };
77
78        let root = rendered.split('.').next().unwrap_or(&rendered);
79        let module = self.imports.resolve(root).map(|m| {
80            // module = the import module of the leading name (e.g. "os" for
81            // os.getcwd, "subprocess.run" for `from subprocess import run`),
82            // not the full call path.
83            m.to_string()
84        });
85
86        let qualified = module.is_some();
87        let kind = if rendered.contains('.') && module.is_none() {
88            RefKind::Method
89        } else {
90            RefKind::Free
91        };
92
93        let (line, col) = leftmost_name(&call.func)
94            .map(|anchor| {
95                let byte_off = anchor_of_subslice(self.span.src(), anchor.value);
96                self.span.line_col(byte_off)
97            })
98            .unwrap_or((0, 0));
99
100        let first_party = self.imports.is_relative(root);
101
102        let resolved_target = resolve_in_project(
103            &rendered,
104            root,
105            &module,
106            self.imports,
107            self.referencing_module,
108            self.referencing_is_package,
109            self.module_map,
110        );
111
112        self.refs.push(CallSiteRef {
113            kind,
114            base: rendered,
115            module,
116            line,
117            col,
118            qualified,
119            first_party,
120            resolved_target,
121        });
122    }
123
124    fn on_assert(&mut self, _assert: &libcst_native::Assert) {}
125    fn on_raise(&mut self, _raise: &libcst_native::Raise) {}
126    fn on_assign_target(&mut self, _target: &libcst_native::AssignTargetExpression, _is_aug: bool) {
127    }
128}
129
130/// The unified expand-split-resolve rule for `resolved_target` (spec 025-3e §5.3).
131///
132/// `base` is the full rendered callee (e.g. `"os.getcwd"`, `"run"`, `"pkg.util.write"`).
133/// `root` is `base.split('.').next()`.
134/// `module` is `imports.resolve(root)` (already computed by the caller).
135///
136/// Step A — expand to a full dotted callee path:
137/// - `RefKind::Method` (`base.contains('.')` AND `module.is_none()`) → `None` (unresolvable
138///   receiver call; `module` being `None` is exactly the `kind == Method` condition).
139/// - If `module` (= R) is `Some`:
140///   - If R is a segment-boundary prefix of `base` (the `import a.b.c` form, where the code
141///     already spells the full path) → `full = base` (use as-is).
142///   - Else (the `from m import n` form) → `full = R + base[root.len()..]` (replace root
143///     with its resolved dotted path, keep any trailing `.member` suffix).
144/// - If `module` is `None` AND `base` has no `.` (bare free call) → same-module candidate:
145///   `[..referencing_module, root]`.
146/// - Else → `None`.
147///
148/// Step B — split: `name = last segment of full`, `target_module = all-but-last joined`.
149///
150/// Step C — resolve `target_module` against the map; emit `[..key, name]` on hit, else `None`.
151fn resolve_in_project(
152    base: &str,
153    root: &str,
154    module: &Option<String>,
155    imports: &Imports,
156    referencing_module: &[String],
157    referencing_is_package: bool,
158    module_map: &crate::module_map::PyModuleMap,
159) -> Option<Vec<String>> {
160    // Step A — expand base to full dotted callee path.
161    let full: String = match module {
162        Some(r) => {
163            // Is R a segment-boundary prefix of base?
164            // A segment-boundary prefix means `base` starts with `r` followed by either
165            // end-of-string or a `.` (so we don't match `os` as a prefix of `osx`).
166            let r_is_segment_prefix = base == r.as_str() || base.starts_with(&format!("{r}."));
167            if r_is_segment_prefix {
168                // `import a.b.c` form — base already spells the full path.
169                base.to_string()
170            } else {
171                // `from m import n` form — replace root with R, keep trailing suffix.
172                // base[root.len()..] is either "" (bare `n()`) or ".member..." (`n.method()`).
173                format!("{r}{}", &base[root.len()..])
174            }
175        }
176        None => {
177            if !base.contains('.') {
178                // Bare free call, no import → same-module candidate.
179                let mut target: Vec<String> = referencing_module.to_vec();
180                target.push(root.to_string());
181                return Some(target);
182            } else {
183                // Method call on a non-imported receiver → unresolvable.
184                return None;
185            }
186        }
187    };
188
189    // Step B — split: name = last segment, target_module = all-but-last.
190    // A bare relative from-import like `from . import write` expands to a dot-free
191    // `full` (e.g. `"write"`). In that case target_module is empty and the anchor
192    // package is resolved by `resolve_relative("", level)` below (Step C).
193    let (target_module, name): (&str, &str) = match full.rfind('.') {
194        Some(p) => (&full[..p], &full[p + 1..]),
195        None => ("", full.as_str()), // relative bare from-import: name = full, pkg via resolve_relative("")
196    };
197
198    // Step C — resolve target_module; emit [..key, name] on hit.
199    let key = if let Some(level) = imports.relative_level(root) {
200        module_map.resolve_relative(
201            referencing_module,
202            referencing_is_package,
203            level,
204            target_module,
205        )?
206    } else {
207        module_map.resolve_absolute(target_module)?
208    };
209
210    let mut result = key;
211    result.push(name.to_string());
212    Some(result)
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::functions;
219    use crate::module_map::PyModuleMap;
220    use fxrank_core::frontend::SourceFile;
221    use fxrank_core::record::RefKind;
222
223    /// Parse inline source and return refs for the function named `sym`.
224    fn refs_for(src: &str, sym: &str) -> Vec<CallSiteRef> {
225        let module = libcst_native::parse_module(src, None).unwrap();
226        let imports = Imports::build(&module);
227        let span = SpanIndex::new(src);
228        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
229        let (units, _) = functions::collect(&module, src, &span, &anchors);
230        let unit = units
231            .iter()
232            .find(|u| u.symbol == sym)
233            .unwrap_or_else(|| panic!("symbol {sym} not found"));
234        let empty_map = PyModuleMap::build(&[]);
235        extract(unit, &imports, &span, &[], false, &empty_map)
236    }
237
238    /// Parse `src` as if it lives at `file`, build a `PyModuleMap` from `batch_files`,
239    /// and return refs for the function named `sym`. The referencing module is derived
240    /// from `file` via the map.
241    fn refs_with_map(src: &str, file: &str, sym: &str, batch_files: &[&str]) -> Vec<CallSiteRef> {
242        let files: Vec<SourceFile> = batch_files
243            .iter()
244            .map(|p| SourceFile {
245                path: p.to_string(),
246                text: String::new(),
247            })
248            .collect();
249        let module_map = PyModuleMap::build(&files);
250        let module = libcst_native::parse_module(src, None).unwrap();
251        let imports = Imports::build(&module);
252        let span = SpanIndex::new(src);
253        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
254        let (units, _) = functions::collect(&module, src, &span, &anchors);
255        let unit = units
256            .iter()
257            .find(|u| u.symbol == sym)
258            .unwrap_or_else(|| panic!("symbol {sym} not found"));
259        let referencing_module = module_map.module_of(file).unwrap_or_default();
260        let referencing_is_package = module_map.is_package(file);
261        extract(
262            unit,
263            &imports,
264            &span,
265            &referencing_module,
266            referencing_is_package,
267            &module_map,
268        )
269    }
270
271    #[test]
272    fn extracts_refs_with_qualified_rule() {
273        let src = "import os\nfrom sub import run\ndef f():\n    os.getcwd()\n    run()\n    self.foo()\n    bare()\n";
274        let refs = refs_for(src, "f");
275
276        // os.getcwd — root `os` resolves to import `os`, qualified=true
277        let os_ref = refs
278            .iter()
279            .find(|r| r.base == "os.getcwd")
280            .unwrap_or_else(|| panic!("os.getcwd not found; refs: {refs:?}"));
281        assert_eq!(
282            os_ref.module.as_deref(),
283            Some("os"),
284            "os.getcwd module must be Some(\"os\")"
285        );
286        assert!(os_ref.qualified, "os.getcwd must be qualified=true");
287
288        // run — resolves to `sub.run`, qualified=true, kind=Free (no dot in base, module present)
289        let run_ref = refs
290            .iter()
291            .find(|r| r.base == "run")
292            .unwrap_or_else(|| panic!("run not found; refs: {refs:?}"));
293        assert_eq!(
294            run_ref.module.as_deref(),
295            Some("sub.run"),
296            "run module must be Some(\"sub.run\")"
297        );
298        assert!(run_ref.qualified, "run must be qualified=true");
299
300        // self.foo — root `self` not imported, qualified=false, kind=Method (has dot, no module)
301        let self_foo = refs
302            .iter()
303            .find(|r| r.base == "self.foo")
304            .unwrap_or_else(|| panic!("self.foo not found; refs: {refs:?}"));
305        assert_eq!(self_foo.module, None, "self.foo module must be None");
306        assert!(!self_foo.qualified, "self.foo must be qualified=false");
307        assert!(
308            matches!(self_foo.kind, RefKind::Method),
309            "self.foo must be RefKind::Method"
310        );
311
312        // bare — root `bare` not imported, qualified=false, kind=Free
313        let bare_ref = refs
314            .iter()
315            .find(|r| r.base == "bare")
316            .unwrap_or_else(|| panic!("bare not found; refs: {refs:?}"));
317        assert_eq!(bare_ref.module, None, "bare module must be None");
318        assert!(!bare_ref.qualified, "bare must be qualified=false");
319    }
320
321    #[test]
322    fn first_party_set_for_relative_imports() {
323        let src = "from .utils import helper\nfrom . import sibling\nimport os\ndef f():\n    helper()\n    sibling.thing()\n    os.getcwd()\n";
324        let refs = refs_for(src, "f");
325
326        let helper_ref = refs
327            .iter()
328            .find(|r| r.base == "helper")
329            .unwrap_or_else(|| panic!("helper not found; refs: {refs:?}"));
330        assert!(helper_ref.first_party, "helper must be first_party=true");
331
332        let sibling_ref = refs
333            .iter()
334            .find(|r| r.base == "sibling.thing")
335            .unwrap_or_else(|| panic!("sibling.thing not found; refs: {refs:?}"));
336        assert!(
337            sibling_ref.first_party,
338            "sibling.thing must be first_party=true"
339        );
340
341        let os_ref = refs
342            .iter()
343            .find(|r| r.base == "os.getcwd")
344            .unwrap_or_else(|| panic!("os.getcwd not found; refs: {refs:?}"));
345        assert!(!os_ref.first_party, "os.getcwd must be first_party=false");
346    }
347
348    #[test]
349    fn line_is_populated() {
350        let src = "import os\ndef f():\n    os.getcwd()\n";
351        let refs = refs_for(src, "f");
352        let r = refs
353            .iter()
354            .find(|r| r.base == "os.getcwd")
355            .expect("os.getcwd not found");
356        assert_eq!(r.line, 3, "os.getcwd is on line 3");
357        assert!(r.col >= 1);
358    }
359
360    #[test]
361    fn absolute_in_batch_import_resolves() {
362        // from pkg.util import write; write()  → ["pkg","util","write"]
363        let src = "from pkg.util import write\ndef caller():\n    write()\n";
364        let refs = refs_with_map(
365            src,
366            "pkg/app.py",
367            "caller",
368            &["pkg/__init__.py", "pkg/app.py", "pkg/util.py"],
369        );
370        let r = refs.iter().find(|r| r.base == "write").unwrap();
371        assert_eq!(
372            r.resolved_target,
373            Some(vec!["pkg".into(), "util".into(), "write".into()])
374        );
375    }
376
377    #[test]
378    fn stdlib_import_stays_unresolved_for_opaque() {
379        // from subprocess import run; run()  → None (not in batch → opaque, the false-resolve fix)
380        let src = "from subprocess import run\ndef caller():\n    run(['ls'])\n";
381        let refs = refs_with_map(
382            src,
383            "pkg/app.py",
384            "caller",
385            &["pkg/__init__.py", "pkg/app.py"],
386        );
387        let r = refs.iter().find(|r| r.base == "run").unwrap();
388        assert_eq!(
389            r.resolved_target, None,
390            "subprocess.run must be unresolved (→ opaque), never a local run"
391        );
392    }
393
394    #[test]
395    fn relative_import_resolves_with_level() {
396        // in pkg.sub.mod: from ..util import write
397        let src = "from ..util import write\ndef caller():\n    write()\n";
398        let refs = refs_with_map(
399            src,
400            "pkg/sub/mod.py",
401            "caller",
402            &[
403                "pkg/__init__.py",
404                "pkg/sub/__init__.py",
405                "pkg/sub/mod.py",
406                "pkg/util.py",
407            ],
408        );
409        let r = refs.iter().find(|r| r.base == "write").unwrap();
410        assert_eq!(
411            r.resolved_target,
412            Some(vec!["pkg".into(), "util".into(), "write".into()])
413        );
414    }
415
416    #[test]
417    fn dotted_module_member_call_resolves() {
418        // import pkg.util; pkg.util.write()  → ["pkg","util","write"] (unified expand-split rule)
419        let src = "import pkg.util\ndef caller():\n    pkg.util.write()\n";
420        let refs = refs_with_map(
421            src,
422            "pkg/app.py",
423            "caller",
424            &["pkg/__init__.py", "pkg/app.py", "pkg/util.py"],
425        );
426        let r = refs.iter().find(|r| r.base == "pkg.util.write").unwrap();
427        assert_eq!(
428            r.resolved_target,
429            Some(vec!["pkg".into(), "util".into(), "write".into()])
430        );
431    }
432
433    #[test]
434    fn method_call_on_from_imported_value_is_unresolved() {
435        // from pkg import Client; Client.get()  — Client is a CLASS (pkg.Client is NOT an in-batch
436        // module), so the expand→ "pkg.Client.get" → module "pkg.Client" → resolve_absolute miss →
437        // None. Must NOT resolve `get` to a coincidental module member (never-guess).
438        let src = "from pkg import Client\ndef caller():\n    Client.get()\n";
439        let refs = refs_with_map(
440            src,
441            "pkg/app.py",
442            "caller",
443            &["pkg/__init__.py", "pkg/app.py"],
444        );
445        let r = refs.iter().find(|r| r.base.starts_with("Client")).unwrap();
446        assert_eq!(
447            r.resolved_target, None,
448            "method call on a from-imported value must be opaque"
449        );
450    }
451
452    #[test]
453    fn same_module_bare_call_resolves_to_own_module() {
454        let src = "def helper():\n    pass\ndef caller():\n    helper()\n";
455        let refs = refs_with_map(
456            src,
457            "pkg/app.py",
458            "caller",
459            &["pkg/__init__.py", "pkg/app.py"],
460        );
461        let r = refs.iter().find(|r| r.base == "helper").unwrap();
462        assert_eq!(
463            r.resolved_target,
464            Some(vec!["pkg".into(), "app".into(), "helper".into()])
465        );
466    }
467
468    #[test]
469    fn relative_bare_from_import_resolves_to_package_member() {
470        // pkg/sub/mod.py: `from . import write; write()`
471        // `from . import write` → level=1, full="write" (no module_path), so rfind('.') was
472        // returning None before the fix (the call went opaque). With the fix, target_module=""
473        // and resolve_relative("pkg.sub.mod", is_package=false, level=1, suffix="") →
474        // anchor=pkg.sub, up=0 → pkg.sub (= pkg/sub/__init__.py key) → [pkg,sub,write].
475        let src = "from . import write\ndef caller():\n    write()\n";
476        let refs = refs_with_map(
477            src,
478            "pkg/sub/mod.py",
479            "caller",
480            &[
481                "pkg/__init__.py",
482                "pkg/sub/__init__.py",
483                "pkg/sub/mod.py",
484                "pkg/sub/write.py",
485            ],
486        );
487        let r = refs.iter().find(|r| r.base == "write").unwrap();
488        assert_eq!(
489            r.resolved_target,
490            Some(vec!["pkg".into(), "sub".into(), "write".into()])
491        );
492    }
493}