1use 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
25pub 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 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
130fn 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 let full: String = match module {
162 Some(r) => {
163 let r_is_segment_prefix = base == r.as_str() || base.starts_with(&format!("{r}."));
167 if r_is_segment_prefix {
168 base.to_string()
170 } else {
171 format!("{r}{}", &base[root.len()..])
174 }
175 }
176 None => {
177 if !base.contains('.') {
178 let mut target: Vec<String> = referencing_module.to_vec();
180 target.push(root.to_string());
181 return Some(target);
182 } else {
183 return None;
185 }
186 }
187 };
188
189 let (target_module, name): (&str, &str) = match full.rfind('.') {
194 Some(p) => (&full[..p], &full[p + 1..]),
195 None => ("", full.as_str()), };
197
198 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 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 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 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 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 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 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 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 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 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 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 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 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}