1use std::collections::{HashMap, HashSet};
32
33use libcst_native::{
34 AssignTargetExpression, CompoundStatement, Element, Expression, ImportNames, Module,
35 NameOrAttribute, OrElse, SmallStatement, Statement, Suite,
36};
37
38pub struct Imports {
41 table: HashMap<String, String>,
43 relative_levels: HashMap<String, usize>,
48 dynamic: bool,
50}
51
52impl Imports {
53 pub fn build(module: &Module) -> Self {
62 let mut table: HashMap<String, String> = HashMap::new();
63 let mut relative_levels: HashMap<String, usize> = HashMap::new();
64 let mut dynamic = false;
65 for stmt in &module.body {
66 collect_stmt(stmt, &mut table, &mut relative_levels, &mut dynamic);
67 }
68 Self {
69 table,
70 relative_levels,
71 dynamic,
72 }
73 }
74
75 pub fn resolve(&self, local: &str) -> Option<&str> {
79 self.table.get(local).map(|s| s.as_str())
80 }
81
82 pub fn is_relative(&self, local: &str) -> bool {
86 self.relative_levels.contains_key(local)
87 }
88
89 pub fn relative_level(&self, local: &str) -> Option<usize> {
93 self.relative_levels.get(local).copied()
94 }
95
96 pub fn has_dynamic(&self) -> bool {
100 self.dynamic
101 }
102}
103
104fn collect_stmt(
109 stmt: &Statement,
110 table: &mut HashMap<String, String>,
111 relative_levels: &mut HashMap<String, usize>,
112 dynamic: &mut bool,
113) {
114 match stmt {
115 Statement::Simple(line) => {
116 for small in &line.body {
117 collect_small(small, table, relative_levels, dynamic);
118 }
119 }
120 Statement::Compound(c) => collect_compound(c, table, relative_levels, dynamic),
121 }
122}
123
124fn collect_suite(
125 suite: &Suite,
126 table: &mut HashMap<String, String>,
127 relative_levels: &mut HashMap<String, usize>,
128 dynamic: &mut bool,
129) {
130 match suite {
131 Suite::IndentedBlock(b) => {
132 for stmt in &b.body {
133 collect_stmt(stmt, table, relative_levels, dynamic);
134 }
135 }
136 Suite::SimpleStatementSuite(s) => {
137 for small in &s.body {
138 collect_small(small, table, relative_levels, dynamic);
139 }
140 }
141 }
142}
143
144fn collect_compound(
145 c: &CompoundStatement,
146 table: &mut HashMap<String, String>,
147 relative_levels: &mut HashMap<String, usize>,
148 dynamic: &mut bool,
149) {
150 match c {
151 CompoundStatement::FunctionDef(d) => {
152 collect_suite(&d.body, table, relative_levels, dynamic)
153 }
154 CompoundStatement::ClassDef(d) => collect_suite(&d.body, table, relative_levels, dynamic),
155 CompoundStatement::If(i) => {
156 collect_suite(&i.body, table, relative_levels, dynamic);
157 if let Some(orelse) = &i.orelse {
158 collect_orelse(orelse, table, relative_levels, dynamic);
159 }
160 }
161 CompoundStatement::For(f) => {
162 collect_suite(&f.body, table, relative_levels, dynamic);
163 if let Some(e) = &f.orelse {
164 collect_suite(&e.body, table, relative_levels, dynamic);
165 }
166 }
167 CompoundStatement::While(w) => {
168 collect_suite(&w.body, table, relative_levels, dynamic);
169 if let Some(e) = &w.orelse {
170 collect_suite(&e.body, table, relative_levels, dynamic);
171 }
172 }
173 CompoundStatement::Try(t) => {
174 collect_suite(&t.body, table, relative_levels, dynamic);
175 for h in &t.handlers {
176 collect_suite(&h.body, table, relative_levels, dynamic);
177 }
178 if let Some(e) = &t.orelse {
179 collect_suite(&e.body, table, relative_levels, dynamic);
180 }
181 if let Some(e) = &t.finalbody {
182 collect_suite(&e.body, table, relative_levels, dynamic);
183 }
184 }
185 CompoundStatement::TryStar(t) => {
186 collect_suite(&t.body, table, relative_levels, dynamic);
187 for h in &t.handlers {
188 collect_suite(&h.body, table, relative_levels, dynamic);
189 }
190 if let Some(e) = &t.orelse {
191 collect_suite(&e.body, table, relative_levels, dynamic);
192 }
193 if let Some(e) = &t.finalbody {
194 collect_suite(&e.body, table, relative_levels, dynamic);
195 }
196 }
197 CompoundStatement::With(w) => collect_suite(&w.body, table, relative_levels, dynamic),
198 CompoundStatement::Match(m) => {
199 for case in &m.cases {
200 collect_suite(&case.body, table, relative_levels, dynamic);
201 }
202 }
203 }
204}
205
206fn collect_orelse(
207 orelse: &OrElse,
208 table: &mut HashMap<String, String>,
209 relative_levels: &mut HashMap<String, usize>,
210 dynamic: &mut bool,
211) {
212 match orelse {
213 OrElse::Elif(elif) => {
214 collect_suite(&elif.body, table, relative_levels, dynamic);
215 if let Some(inner) = &elif.orelse {
216 collect_orelse(inner, table, relative_levels, dynamic);
217 }
218 }
219 OrElse::Else(e) => collect_suite(&e.body, table, relative_levels, dynamic),
220 }
221}
222
223fn collect_small(
225 small: &SmallStatement,
226 table: &mut HashMap<String, String>,
227 relative_levels: &mut HashMap<String, usize>,
228 dynamic: &mut bool,
229) {
230 match small {
231 SmallStatement::Import(imp) => {
233 for alias in &imp.names {
234 let path = noa_to_string(&alias.name);
235 if path == "importlib" || path.starts_with("importlib.") {
238 *dynamic = true;
239 }
240 let local = if let Some(asname) = &alias.asname {
241 ate_to_string(&asname.name)
242 } else {
243 root_component(&path)
245 };
246 table.insert(local, path);
247 }
249 }
250 SmallStatement::ImportFrom(from) => {
253 let level = from.relative.len();
254 let module_path = from
255 .module
256 .as_ref()
257 .map(|m| noa_to_string(m))
258 .unwrap_or_default();
259 if module_path == "importlib" || module_path.starts_with("importlib.") {
260 *dynamic = true;
261 }
262 let ImportNames::Aliases(aliases) = &from.names else {
263 return;
265 };
266 for alias in aliases {
267 let name = noa_to_string(&alias.name);
268 if name == "__import__" {
269 *dynamic = true;
270 }
271 let full = if module_path.is_empty() {
272 name.clone()
273 } else {
274 format!("{module_path}.{name}")
275 };
276 let local = if let Some(asname) = &alias.asname {
277 ate_to_string(&asname.name)
278 } else {
279 name
280 };
281 if level > 0 {
282 relative_levels.insert(local.clone(), level);
283 }
284 table.insert(local, full);
285 }
286 }
287 _ => {}
288 }
289}
290
291fn noa_to_string(noa: &NameOrAttribute) -> String {
297 match noa {
298 NameOrAttribute::N(n) => n.value.to_owned(),
299 NameOrAttribute::A(a) => {
300 let mut parts: Vec<String> = Vec::new();
302 collect_attr_parts_expr(&a.value, &mut parts);
303 parts.push(a.attr.value.to_owned());
304 parts.join(".")
305 }
306 }
307}
308
309fn collect_attr_parts_expr(expr: &Expression, out: &mut Vec<String>) {
312 match expr {
313 Expression::Name(n) => out.push(n.value.to_owned()),
314 Expression::Attribute(a) => {
315 collect_attr_parts_expr(&a.value, out);
316 out.push(a.attr.value.to_owned());
317 }
318 _ => {}
320 }
321}
322
323fn ate_to_string(ate: &AssignTargetExpression) -> String {
329 match ate {
330 AssignTargetExpression::Name(n) => n.value.to_owned(),
331 _ => String::new(),
332 }
333}
334
335fn root_component(path: &str) -> String {
339 path.split('.').next().unwrap_or(path).to_owned()
340}
341
342pub fn module_bindings(module: &Module) -> HashSet<String> {
367 let mut out = HashSet::new();
368 for stmt in &module.body {
369 match stmt {
370 Statement::Simple(line) => {
371 for small in &line.body {
372 match small {
373 SmallStatement::Assign(a) => {
374 for target in &a.targets {
375 collect_target_names(&target.target, &mut out);
376 }
377 }
378 SmallStatement::AnnAssign(a) => {
379 collect_target_names(&a.target, &mut out);
380 }
381 _ => {}
382 }
383 }
384 }
385 Statement::Compound(c) => match c {
386 CompoundStatement::FunctionDef(f) => {
387 out.insert(f.name.value.to_owned());
388 }
389 CompoundStatement::ClassDef(c) => {
390 out.insert(c.name.value.to_owned());
391 }
392 _ => {}
393 },
394 }
395 }
396 out
397}
398
399pub(crate) fn collect_target_names(target: &AssignTargetExpression, out: &mut HashSet<String>) {
403 match target {
404 AssignTargetExpression::Name(n) => {
405 out.insert(n.value.to_owned());
406 }
407 AssignTargetExpression::Tuple(t) => {
408 for el in &t.elements {
409 collect_element_names(el, out);
410 }
411 }
412 AssignTargetExpression::List(l) => {
413 for el in &l.elements {
414 collect_element_names(el, out);
415 }
416 }
417 AssignTargetExpression::StarredElement(s) => collect_expr_target_names(&s.value, out),
418 AssignTargetExpression::Attribute(_) | AssignTargetExpression::Subscript(_) => {}
419 }
420}
421
422pub(crate) fn collect_element_names(el: &Element, out: &mut HashSet<String>) {
424 match el {
425 Element::Simple { value, .. } => collect_expr_target_names(value, out),
426 Element::Starred(s) => collect_expr_target_names(&s.value, out),
427 }
428}
429
430pub(crate) fn collect_expr_target_names(expr: &Expression, out: &mut HashSet<String>) {
432 match expr {
433 Expression::Name(n) => {
434 out.insert(n.value.to_owned());
435 }
436 Expression::Tuple(t) => {
437 for el in &t.elements {
438 collect_element_names(el, out);
439 }
440 }
441 Expression::List(l) => {
442 for el in &l.elements {
443 collect_element_names(el, out);
444 }
445 }
446 Expression::StarredElement(s) => collect_expr_target_names(&s.value, out),
447 _ => {}
448 }
449}
450
451#[cfg(test)]
454mod tests {
455 use super::*;
456
457 fn build_str(src: &str) -> Imports {
458 Imports::build(&libcst_native::parse_module(src, None).unwrap())
459 }
460
461 #[test]
462 fn resolves_import_forms() {
463 let i = build_str("import os\nimport numpy as np\nfrom subprocess import run\n");
464 assert_eq!(i.resolve("os"), Some("os"));
465 assert_eq!(i.resolve("np"), Some("numpy"));
466 assert_eq!(i.resolve("run"), Some("subprocess.run"));
467 }
468
469 #[test]
470 fn resolves_dotted_import_without_alias() {
471 let i = build_str("import a.b.c\n");
473 assert_eq!(i.resolve("a"), Some("a.b.c"));
474 assert_eq!(i.resolve("a.b.c"), None);
475 }
476
477 #[test]
478 fn resolves_from_import_with_alias() {
479 let i = build_str("from m import n as p\n");
480 assert_eq!(i.resolve("p"), Some("m.n"));
481 assert_eq!(i.resolve("n"), None);
482 }
483
484 #[test]
485 fn from_import_star_does_not_crash() {
486 let i = build_str("from os.path import *\n");
488 assert_eq!(i.resolve("join"), None);
489 assert!(!i.has_dynamic());
490 }
491
492 #[test]
493 fn detects_importlib_dynamic() {
494 let i = build_str("import importlib\n");
495 assert!(i.has_dynamic());
496 }
497
498 #[test]
499 fn detects_from_importlib_dynamic() {
500 let i = build_str("from importlib import import_module\n");
501 assert!(i.has_dynamic());
502 }
503
504 #[test]
505 fn detects_importlib_submodule_dynamic() {
506 assert!(build_str("import importlib.util\n").has_dynamic());
508 assert!(build_str("from importlib.util import find_spec\n").has_dynamic());
509 }
510
511 #[test]
512 fn no_dynamic_for_normal_imports() {
513 let i = build_str("import os\nfrom sys import path\n");
514 assert!(!i.has_dynamic());
515 }
516
517 #[test]
520 fn resolves_function_local_imports() {
521 let i = build_str("def f():\n import subprocess\n subprocess.run(c, shell=True)\n");
522 assert_eq!(i.resolve("subprocess"), Some("subprocess"));
523 }
524
525 #[test]
528 fn resolves_nested_class_method_imports_and_dynamic() {
529 let i = build_str(
530 "class C:\n def m(self):\n from subprocess import run\n import importlib\n",
531 );
532 assert_eq!(i.resolve("run"), Some("subprocess.run"));
533 assert!(i.has_dynamic());
534 }
535
536 #[test]
537 fn is_relative_detects_leading_dot_imports() {
538 let i = build_str("from .utils import helper\nfrom . import sibling\nimport os\n");
540 assert!(i.is_relative("helper"), "helper must be relative");
541 assert!(i.is_relative("sibling"), "sibling must be relative");
542 assert!(!i.is_relative("os"), "os must not be relative");
543 assert!(!i.is_relative("unknown"), "unknown must not be relative");
544 }
545
546 #[test]
547 fn module_bindings_collects_top_level_only() {
548 let src = "\
549import config\n\
550_counter = 0\n\
551shared_map = {}\n\
552A, B = 1, 2\n\
553[x, y] = [3, 4]\n\
554def helper():\n inner_local = 1\n return inner_local\n\
555class Box:\n pass\n";
556 let module = libcst_native::parse_module(src, None).unwrap();
557 let mb = module_bindings(&module);
558 for name in [
560 "_counter",
561 "shared_map",
562 "A",
563 "B",
564 "x",
565 "y",
566 "helper",
567 "Box",
568 ] {
569 assert!(
570 mb.contains(name),
571 "expected module binding `{name}`, got {mb:?}"
572 );
573 }
574 assert!(
576 !mb.contains("inner_local"),
577 "function-body local leaked into module_bindings"
578 );
579 assert!(
581 !mb.contains("config"),
582 "imported name leaked into module_bindings"
583 );
584 }
585}