Skip to main content

fxrank_lang_python/
imports.rs

1//! Import-resolution table: `import`, `from … import`, and `… as …` forms.
2//!
3//! `Imports::build` walks the **entire file**, recursing into function and class
4//! bodies (nested suites), and fills a map from **local name** → **fully-qualified
5//! module path** (a dot-joined string). Function-local imports are therefore
6//! resolved file-wide (an intentional over-approximation for a syntactic tool).
7//! This mirrors the Rust frontend's `imports` module and feeds the call-site
8//! detector so it can identify high-risk imported symbols.
9//!
10//! # Mapping rules
11//!
12//! | Source form                  | local key | resolved path |
13//! |------------------------------|-----------|---------------|
14//! | `import os`                  | `os`      | `"os"`        |
15//! | `import a.b.c`               | `a`       | `"a.b.c"`     |
16//! | `import numpy as np`         | `np`      | `"numpy"`     |
17//! | `from subprocess import run` | `run`     | `"subprocess.run"` |
18//! | `from m import n as p`       | `p`       | `"m.n"`       |
19//!
20//! For `import a.b.c` (no alias) the local key is the root component (`a`).
21//! Aliases always win: `import a.b as x` → key `x`, path `"a.b"`.
22//!
23//! # `has_dynamic`
24//!
25//! Set to `true` when `importlib` appears as an imported module name or
26//! `__import__` appears as an imported name inside a `from … import` statement.
27//! This detects dynamic-import *infrastructure* being imported, not call-site
28//! usage; actual call-site detection (e.g. `importlib.import_module(…)`) is
29//! handled by `detect/risk.rs`.
30
31use std::collections::{HashMap, HashSet};
32
33use libcst_native::{
34    AssignTargetExpression, CompoundStatement, Element, Expression, ImportNames, Module,
35    NameOrAttribute, OrElse, SmallStatement, Statement, Suite,
36};
37
38/// A resolved import table built from import statements anywhere in a `Module`
39/// (recursing into nested function and class bodies for file-wide resolution).
40pub struct Imports {
41    /// local name → fully-qualified path string.
42    table: HashMap<String, String>,
43    /// Local names that came from a **relative** (leading-dot) `from`-import,
44    /// mapped to their dot-level (`from.relative.len()`).
45    /// e.g. `from . import sibling` → level 1, `from .. import x` → level 2.
46    /// Plain `import` and absolute `from m import n` do not appear here.
47    relative_levels: HashMap<String, usize>,
48    /// `true` when `importlib` or `__import__` appears as an imported name.
49    dynamic: bool,
50}
51
52impl Imports {
53    /// Build an `Imports` table from a parsed `Module`.
54    ///
55    /// Imports are collected **file-wide** — recursing into function and class
56    /// bodies, not just the module's top-level statements — so a function-local
57    /// `def f(): import subprocess; …` still resolves call-site names. The flat
58    /// table is file-scoped (not block-scoped): an import in one function can
59    /// resolve a same-named call in another. This over-approximation is acceptable
60    /// for a syntactic heuristic (see spec *Deferred / Future work*).
61    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    /// Resolve a local name to its fully-qualified module path.
76    ///
77    /// Returns `None` when the name was not introduced by any import statement.
78    pub fn resolve(&self, local: &str) -> Option<&str> {
79        self.table.get(local).map(|s| s.as_str())
80    }
81
82    /// `true` when `local` was introduced by a **relative** (leading-dot)
83    /// `from`-import (`from . import x`, `from .mod import y`, etc.).
84    /// Returns `false` for absolute imports and unknown names.
85    pub fn is_relative(&self, local: &str) -> bool {
86        self.relative_levels.contains_key(local)
87    }
88
89    /// The dot-level of a relative import's local name (`from.relative.len()`).
90    /// `from . import x` → `Some(1)`, `from .. import x` → `Some(2)`.
91    /// Returns `None` for absolute imports and unknown names.
92    pub fn relative_level(&self, local: &str) -> Option<usize> {
93        self.relative_levels.get(local).copied()
94    }
95
96    /// `true` when `importlib` or `__import__` appears as an imported name,
97    /// indicating the file uses dynamic-import infrastructure. Callers may
98    /// apply a confidence penalty when resolving import-dependent signals.
99    pub fn has_dynamic(&self) -> bool {
100        self.dynamic
101    }
102}
103
104// ─── file-wide statement traversal ────────────────────────────────────────────
105
106/// Collect imports from a statement, recursing into compound-statement bodies
107/// (functions, classes, branches, loops) so function-local imports are captured.
108fn 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
223/// Record imports from a single small statement into `table` / `relative_levels` / `dynamic`.
224fn 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        // `import a`, `import a.b.c`, `import a.b as x`
232        SmallStatement::Import(imp) => {
233            for alias in &imp.names {
234                let path = noa_to_string(&alias.name);
235                // Any importlib submodule (`importlib`, `importlib.util`, …) is a
236                // dynamic-import surface, not just the bare package.
237                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                    // bare `import a.b.c` — local key is root component
244                    root_component(&path)
245                };
246                table.insert(local, path);
247                // Plain `import` is never relative — no dots needed here.
248            }
249        }
250        // `from m import n`, `from m import n as p`,
251        // `from . import x`, `from .mod import y` (leading dots = relative)
252        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                // `from m import *` — skip; we can't know the local names
264                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
291// ─── helpers ──────────────────────────────────────────────────────────────────
292
293/// Convert a `NameOrAttribute` node to its dot-joined string representation.
294///
295/// `Name("os")` → `"os"`, `Attribute(Name("a"), Name("b"))` → `"a.b"`.
296fn noa_to_string(noa: &NameOrAttribute) -> String {
297    match noa {
298        NameOrAttribute::N(n) => n.value.to_owned(),
299        NameOrAttribute::A(a) => {
300            // Flatten `value.attr` recursively via the Expression::Attribute arm.
301            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
309/// Collect dot-separated components from an `Expression` that may be a chain
310/// of `Attribute` nodes (e.g. `a.b.c` → `["a", "b", "c"]`).
311fn 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        // Other expression forms are not expected in import positions.
319        _ => {}
320    }
321}
322
323/// Convert an `AssignTargetExpression` (the alias name, e.g. `as np`) to a string.
324///
325/// An import `asname` is always a plain `Name` (`import a as b.c` is a syntax
326/// error), so only the `Name` arm is reachable; all other forms fall back to an
327/// empty string.
328fn ate_to_string(ate: &AssignTargetExpression) -> String {
329    match ate {
330        AssignTargetExpression::Name(n) => n.value.to_owned(),
331        _ => String::new(),
332    }
333}
334
335/// Return the first dot-component of a dotted path string.
336///
337/// `"a.b.c"` → `"a"`, `"os"` → `"os"`.
338fn root_component(path: &str) -> String {
339    path.split('.').next().unwrap_or(path).to_owned()
340}
341
342// ─── module top-level binding collector ───────────────────────────────────────
343
344/// Collect the names introduced by **module top-level** statements of `module`:
345/// assignment targets (`x = …`, `x: T = …`, and destructured `a, b = …` /
346/// `[x, y] = …` / `*rest, last = …`), `def` names, and `class` names. Only the
347/// module body is scanned; names bound inside function bodies are not collected.
348/// A write whose root is one of these — when it is not a local/param/`global`-
349/// declared/import in the writing function — is a write to module-shared state,
350/// escalated to `global.mutation` (the Python analog of #29).
351///
352/// The **function-body prescan** (`detect/mutation.rs`) now covers for/with-as/
353/// except-as locals: a `for _cache in …`, `with ctx as _cache`, or
354/// `except E as _cache` binding shadows the module-level name inside that
355/// function and is collected as a local, preventing false escalation to
356/// `global.mutation`.
357///
358/// Not collected (accepted misses, consistent with the syntactic flat-scope
359/// approximation in the other frontends): import names (handled by the F5 import
360/// arm via the `Imports` table); subscript/attribute assignment targets (not new
361/// bindings); names bound by module top-level `for`/`with … as`/`except … as`/
362/// `match` patterns; names bound only inside nested blocks/comprehensions.
363/// **Residual accepted limits in the prescan** (not chased): `match` pattern
364/// captures, comprehension-scope targets (Python 3 gives them their own scope),
365/// and walrus (`:=`) operator targets.
366pub 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
399/// Collect bound names from an assignment target, recursing into destructuring.
400/// Attribute/Subscript targets bind no new name. Mirrors
401/// `detect::walk_assign_target_subexprs`'s enum shape.
402pub(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
422/// A destructuring element (`(a, *rest) = …`). Mirrors `detect::walk_target_element`.
423pub(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
430/// Destructuring elements are typed as `Expression`. Mirrors `detect::walk_target_value`.
431pub(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// ─── tests ────────────────────────────────────────────────────────────────────
452
453#[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        // `import a.b.c` — local key is root component `a`
472        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        // `from m import *` — nothing resolvable, but must not panic
487        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        // submodules of importlib are also a dynamic-import surface
507        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    /// FIX 5: imports declared INSIDE a function body must be collected file-wide,
518    /// so a function-local `import subprocess` resolves the call-site name.
519    #[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    /// FIX 5: function-local `from … import …` and `importlib` dynamic-flagging
526    /// also work inside nested scopes (class → method).
527    #[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        // `from .utils import helper` and `from . import sibling` are relative
539        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        // Bare names, destructured tuple/list targets, def + class names all collected:
559        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        // Function-body locals are NOT collected:
575        assert!(
576            !mb.contains("inner_local"),
577            "function-body local leaked into module_bindings"
578        );
579        // Imported names live in the Imports table, not here:
580        assert!(
581            !mb.contains("config"),
582            "imported name leaked into module_bindings"
583        );
584    }
585}