Skip to main content

brokk_bifrost_python/
graph_support.rs

1//! The language half of Python's resolution logic: module lookup, the export
2//! index, the import binder, base-class resolution and the skeleton renderer,
3//! written as free functions over a source trait instead of as methods on
4//! `PythonAnalyzer`.
5//!
6//! `PythonAnalyzer` (in `brokk-bifrost-analysis`) owns the lazy cells (seven
7//! moka caches, one `OnceLock` and two `PoolSafeMemo`s) and implements
8//! [`PythonSource`] out of its own accessors, so the functions below
9//! reach back for the memoized products they need without naming the analyzer
10//! type.
11
12use brokk_bifrost_core::analyzer::capabilities::ImportAnalysisProvider;
13use brokk_bifrost_core::analyzer::model::{CodeUnitType, ImportInfo};
14use brokk_bifrost_core::analyzer::prepared_syntax::{IndexedFileFacts, PreparedSyntaxTree};
15use brokk_bifrost_core::analyzer::query_token::QueryToken;
16use brokk_bifrost_core::analyzer::symbol_path::parse_symbol_path;
17use brokk_bifrost_core::analyzer::tree_walk::{WalkControl, walk_named_tree_preorder};
18use brokk_bifrost_core::analyzer::usages::model::{
19    ExportEntry, ExportIndex, ImportBinder, ImportBinding, ImportKind, ReexportStar,
20};
21use brokk_bifrost_core::analyzer::{CodeUnit, CodeUnitIndex, Language, ProjectFile};
22use brokk_bifrost_core::hash::{HashMap, HashSet};
23use std::collections::BTreeSet;
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26use tree_sitter::Node;
27
28use crate::declarations::{
29    collect_python_identifiers, module_code_unit, parse_python_tree, python_declared_import_root,
30    python_import_root, python_module_name, python_project_module_components,
31    python_repeated_root_extension_dir,
32};
33use crate::imports::{
34    PythonImportDetails, python_import_details, python_import_infos_from_node,
35    python_namespace_binding_module, python_namespace_binding_name, resolve_exported_fqn,
36    resolve_import_bindings, resolve_python_relative_module,
37};
38use crate::syntax::{python_plain_string_literal, python_static_attribute_path};
39use crate::usage_index::PythonUsageIndex;
40
41/// The analyzer-resident products Python's language logic resolves through, on
42/// top of the two core capability traits it reads declarations and imports
43/// with. The analyzer is the only implementor and every method forwards to one
44/// of its own accessors, so the cells stay where they are and no free function
45/// can reach past this surface.
46///
47/// The usage index is deliberately absent: [`PythonUsageIndex::build`] and
48/// everything it calls take this trait, so the build cannot re-enter the memo
49/// it is filling. Code that runs once the index exists takes
50/// [`PythonUsageSource`].
51pub trait PythonSource: CodeUnitIndex + ImportAnalysisProvider {
52    /// Path-derived module units for `module_fq`; `None` when the store could
53    /// not answer the path-symbol query at all.
54    fn path_module_fqn(&self, module_fq: &str) -> Option<Vec<CodeUnit>>;
55
56    /// [`Self::path_module_fqn`] for a whole batch, resolved in one store
57    /// transaction.
58    fn path_module_fqns_batch(&self, module_fqs: &[String]) -> Vec<Option<Vec<CodeUnit>>>;
59
60    fn definition_fqn(&self, fqn: &str) -> Vec<CodeUnit>;
61
62    /// Every workspace Python module's project-root name, for resolving an
63    /// absolute specifier written against a source root below the project root.
64    ///
65    /// Memoized for the analyzer generation by the implementor: the product is
66    /// path math over the workspace file listing, and every import whose
67    /// specifier names no indexed module asks for it.
68    fn module_spellings(&self) -> Arc<PythonModuleSpellings>;
69
70    /// Shared by handle: both products are immutable for the analyzer
71    /// generation that cached them, and callers ask for them once per receiver
72    /// type, annotation or export name, so deep-cloning the whole map out of
73    /// the cache on every hit was pure waste.
74    fn import_binder_of(&self, file: &ProjectFile) -> Arc<ImportBinder>;
75
76    fn export_index_of(&self, file: &ProjectFile) -> Arc<ExportIndex>;
77
78    /// The parsed tree and its source backing for `file`, from the analyzer's
79    /// query read cache.
80    ///
81    /// A caller that needs a syntax node for an already-indexed declaration
82    /// must reach it through here rather than re-parsing: `indexed_source`
83    /// hands out an owned copy of the whole file, and building a `Parser` per
84    /// declaration reparses text the analyzer has already parsed. `None` when
85    /// the analyzer holds no prepared tree, which is what keeps the re-parsing
86    /// path alive as a fallback.
87    /// The [`QueryToken`] is proof that a request scope is open, so the cache
88    /// this reads is live (issue #2414 step 3).
89    fn prepared_syntax(
90        &self,
91        token: QueryToken<'_>,
92        file: &ProjectFile,
93    ) -> Option<Arc<PreparedSyntaxTree>>;
94
95    /// Every file's indexed facts, visited in the analyzer's own bulk-read
96    /// batches. `None` marks a file the index carries no record for.
97    fn visit_file_facts(
98        &self,
99        files: &[ProjectFile],
100        visit: &mut dyn FnMut(&ProjectFile, Option<&dyn IndexedFileFacts>),
101    );
102}
103
104/// [`PythonSource`] plus the built usage index. Everything reached from
105/// the export/importer walks needs it; the index build itself must not.
106pub trait PythonUsageSource: PythonSource {
107    fn usage_index(&self) -> Arc<PythonUsageIndex>;
108}
109
110pub fn extract_type_identifiers(source: &str) -> BTreeSet<String> {
111    let Some(tree) = parse_python_tree(source) else {
112        return BTreeSet::new();
113    };
114    let mut identifiers = HashSet::default();
115    collect_python_identifiers(tree.root_node(), source, &mut identifiers);
116    identifiers.into_iter().collect()
117}
118
119/// Keep only the candidates an absolute import written in `importer` can mean.
120///
121/// A Python module name is path-derived and relative to an import root, so one
122/// snapshot that vendors two distributions side by side spells the same module
123/// twice: Feathr's `registry/purview-registry/registry/models.py` and
124/// `registry/sql-registry/registry/models.py` are both `registry.models`
125/// (#3475). Python resolves an absolute import against the `sys.path` entries
126/// the importing program runs with, and the entry always on it is the root the
127/// importing file itself lives under, so that root's module is the one the
128/// import means. An enclosing root qualifies as well as the importer's own
129/// package root, which is what lets a distribution's top-level script import
130/// its own packages; when more than one qualifies, the nearest one shadows the
131/// rest the way a closer `sys.path` entry does.
132///
133/// A candidate set that no containing root owns survives whole. That leaves a
134/// genuine cross-root import resolvable and keeps the ambiguity visible to
135/// whichever caller asked for a unique identity, rather than inventing a
136/// preference the workspace does not state.
137pub fn retain_modules_for_importer<T>(
138    importer: &ProjectFile,
139    candidates: &mut Vec<T>,
140    source_of: impl Fn(&T) -> &ProjectFile,
141) {
142    retain_nearest_owning_root(importer, candidates, |candidate| {
143        let source = source_of(candidate);
144        debug_assert_eq!(
145            importer.root(),
146            source.root(),
147            "one workspace's Python files share one project root"
148        );
149        python_import_root(source)
150    });
151}
152
153/// The `sys.path`-entry shadowing rule on its own: keep the candidates whose
154/// root contains `importer`, nearest first, and leave a set no candidate root
155/// owns untouched.
156///
157/// Shared by the two ways a candidate gets a root. A candidate found by its
158/// identity has the root [`python_import_root`] named for it; a candidate found
159/// by [`PythonModuleSpellings`] has the root the specifier was written against.
160/// Both are resolved against the importing program's path the same way.
161fn retain_nearest_owning_root<T>(
162    importer: &ProjectFile,
163    candidates: &mut Vec<T>,
164    root_of: impl Fn(&T) -> PathBuf,
165) {
166    if candidates.len() < 2 {
167        return;
168    }
169    let owning_depth = |candidate: &T| {
170        let root = root_of(candidate);
171        importer
172            .rel_path()
173            .starts_with(&root)
174            .then(|| root.components().count())
175    };
176    let depths = candidates.iter().map(owning_depth).collect::<Vec<_>>();
177    let Some(nearest) = depths.iter().copied().flatten().max() else {
178        return;
179    };
180    *candidates = std::mem::take(candidates)
181        .into_iter()
182        .zip(depths)
183        .filter(|(_, depth)| *depth == Some(nearest))
184        .map(|(candidate, _)| candidate)
185        .collect();
186}
187
188/// One workspace Python module, named from the project root.
189#[derive(Clone, Debug)]
190struct ProjectRootModuleName {
191    file: ProjectFile,
192    components: Vec<String>,
193}
194
195/// A module whose project-root name *ends with* an absolute specifier, and the
196/// source root that spelling would have to be written against.
197///
198/// This is candidate evidence, not binding evidence: a tail match only says the
199/// file could be the module if its implied root were on the importing program's
200/// path. [`PythonModuleSpellings::establishes_source_root`] decides whether the
201/// workspace's own structure makes that root real, and only an established
202/// candidate ever binds.
203#[derive(Clone, Debug)]
204pub struct PythonSourceRootModule {
205    pub file: ProjectFile,
206    /// The directory the specifier is resolved against: `file`'s relative path
207    /// with the specifier's own components removed. Never empty, because the
208    /// project root is the spelling the module index already answers exactly.
209    pub source_root: PathBuf,
210}
211
212/// Every workspace Python module's project-root name, keyed by its last
213/// component.
214///
215/// A Python module name is path-derived and relative to a `sys.path` entry, so
216/// one file has one valid absolute name per ancestor directory on that path:
217/// `dependency/core/lib/common/__init__.py` is `dependency.core.lib.common`
218/// from the project root and `core.lib.common` from `dependency/`, which is the
219/// entry dayu's `PYTHONPATH` adds and the only one its sources ever write
220/// (#3506). Exactly one of those names is the module's identity, so an import
221/// written against any other root has to be matched against the *end* of the
222/// project-root name. The last component is the one part every name of a module
223/// shares, so it is the key.
224///
225/// Built from path math over the workspace file listing: no parse, no store
226/// read, and no `sys.path` guess -- a candidate only ever comes back paired
227/// with the root that would have to be on the path for it to be the answer.
228/// The same listing states which directories are packages, which is the
229/// evidence a root without a packaging declaration is established by. A root
230/// below a packaging declaration is established by the one thing the listing
231/// cannot state -- the package marker's own `__path__` extension -- so those
232/// markers, and only the markers that could state one, are read.
233#[derive(Debug, Default)]
234pub struct PythonModuleSpellings {
235    by_last_component: HashMap<String, Vec<ProjectRootModuleName>>,
236    /// Every directory of the snapshot that holds an `__init__.py`, i.e. every
237    /// package directory the workspace states.
238    package_dirs: HashSet<PathBuf>,
239    /// The directories a package marker adds to its own `__path__`, by the
240    /// package directory that states them, once the statements a later
241    /// `__path__` assignment replaces are dropped.
242    ///
243    /// `rtdetr_pose/__init__.py` appending `rtdetr_pose/rtdetr_pose` is what
244    /// makes the shorter spelling `rtdetr_pose.config` name the file the
245    /// declared root already names `rtdetr_pose.rtdetr_pose.config`, and it is
246    /// the only thing that does: the repeated directory name, and the empty
247    /// markers that make the directories packages, state nothing -- nor does
248    /// an append in a branch that never runs, one whose guard another branch
249    /// holds, or one a later `__path__ = ...` discards (#3506).
250    path_extensions: HashMap<PathBuf, HashSet<PathBuf>>,
251}
252
253impl PythonModuleSpellings {
254    /// Index the snapshot's Python files.
255    ///
256    /// `read_source` answers a file's bytes from the same snapshot the listing
257    /// came from -- the project's own read, so an overlay's unsaved marker text
258    /// is what the evidence sees -- and is called only for markers that can
259    /// state a path extension.
260    pub fn build(
261        files: impl IntoIterator<Item = ProjectFile>,
262        mut read_source: impl FnMut(&ProjectFile) -> Option<String>,
263    ) -> Self {
264        let mut by_last_component: HashMap<String, Vec<ProjectRootModuleName>> = HashMap::default();
265        let mut package_dirs: HashSet<PathBuf> = HashSet::default();
266        let mut package_markers: Vec<ProjectFile> = Vec::new();
267        for file in files {
268            if file.rel_path().file_name().and_then(|name| name.to_str()) == Some("__init__.py")
269                && let Some(directory) = file.rel_path().parent()
270            {
271                package_dirs.insert(directory.to_path_buf());
272                package_markers.push(file.clone());
273            }
274            let components = python_project_module_components(&file);
275            let Some(last) = components.last() else {
276                continue;
277            };
278            by_last_component
279                .entry(last.clone())
280                .or_default()
281                .push(ProjectRootModuleName { file, components });
282        }
283        let mut path_extensions: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::default();
284        for marker in package_markers {
285            let Some(directory) = marker.rel_path().parent() else {
286                continue;
287            };
288            if !python_package_holds_a_repeat_of_itself(directory, &package_dirs) {
289                continue;
290            }
291            let Some(source) = read_source(&marker) else {
292                continue;
293            };
294            let extended = python_appended_path_dirs(&source, marker.rel_path());
295            if !extended.is_empty() {
296                path_extensions
297                    .entry(directory.to_path_buf())
298                    .or_default()
299                    .extend(extended);
300            }
301        }
302        Self {
303            by_last_component,
304            package_dirs,
305            path_extensions,
306        }
307    }
308
309    /// The modules whose project-root name ends with `module_fq`, each with the
310    /// source root that would make the shorter name the right one.
311    ///
312    /// The project-root name itself is deliberately absent: that one is the
313    /// module's identity, which the module index answers exactly, so this
314    /// reports only the additional names and only for a dotted specifier. A
315    /// single-component specifier names a top-level module, every top-level
316    /// workspace module is already named from the project root, and a bare name
317    /// that merely matches the last component of a deeper module is a
318    /// coincidence rather than a source root -- the stdlib `typing` must not
319    /// resolve to a workspace's own `seaborn._core.typing`.
320    pub fn suffix_candidates(&self, module_fq: &str) -> Vec<PythonSourceRootModule> {
321        // A leading dot makes the specifier relative to the importing file's own
322        // package, which `resolve_python_relative_module` resolves before any
323        // lookup; no `sys.path` entry takes part in it.
324        if module_fq.starts_with('.') {
325            return Vec::new();
326        }
327        let components = parse_symbol_path(Language::Python, module_fq);
328        let Some(last) = components.last().filter(|_| components.len() > 1) else {
329            return Vec::new();
330        };
331        let mut modules = Vec::new();
332        for candidate in self
333            .by_last_component
334            .get(last.as_str())
335            .into_iter()
336            .flatten()
337        {
338            // A proper suffix only, so a shorter or equal-length name cannot
339            // match: an equal one is the identity this does not answer.
340            let Some(dropped) = candidate
341                .components
342                .len()
343                .checked_sub(components.len())
344                .filter(|dropped| *dropped > 0)
345            else {
346                continue;
347            };
348            if candidate.components[dropped..] != components[..] {
349                continue;
350            }
351            // The dropped components are the leading directories of the file's
352            // own relative path, so they are the source root exactly.
353            let source_root: PathBuf = candidate
354                .file
355                .rel_path()
356                .components()
357                .take(dropped)
358                .collect();
359            debug_assert!(
360                !source_root.as_os_str().is_empty(),
361                "dropping a component of {:?} leaves a directory",
362                candidate.file.rel_path()
363            );
364            modules.push(PythonSourceRootModule {
365                file: candidate.file.clone(),
366                source_root,
367            });
368        }
369        modules
370    }
371
372    /// Whether the workspace's own structure establishes `candidate`'s implied
373    /// source root as a root the candidate's specifier can be written against.
374    ///
375    /// Two kinds of evidence count, and a candidate with neither stays only a
376    /// candidate.
377    ///
378    /// A packaging manifest that declares a root for the candidate's file
379    /// (`tool.setuptools.packages.find.where`, `setup.py`'s `package_dir`) is
380    /// the workspace stating the root itself, and it is authoritative for the
381    /// files below it: the specifier is then the file's declared module name
382    /// and nowhere else. `source/realpkg/vendor/api.py` under
383    /// `where = ["source"]` is `realpkg.vendor.api`, so `vendor.api` -- whose
384    /// implied root is `source/realpkg` -- does not name it, even though a
385    /// package chain runs from that implied root down to it. A declaration of
386    /// the project root counts the same way: `where = ["."]` is the workspace
387    /// saying its packages begin at the project root, which is not the same as
388    /// saying nothing, so `otherpkg/vendor/api.py` is `otherpkg.vendor.api` and
389    /// `vendor.api` -- whose implied root is `otherpkg`, a package the
390    /// declaration names -- does not name it either (#3506).
391    ///
392    /// A declaration leaves one shorter spelling open, the double-nested
393    /// distribution directory, because there the specifier writes the implied
394    /// root's own name again before the module's path:
395    /// `rtdetr_pose/rtdetr_pose/config.py` is `rtdetr_pose.config` from
396    /// `rtdetr_pose/`. The shape is not the evidence, because it is only a
397    /// repeated directory name: the package the specifier starts from has to
398    /// state that the repeated directory is on its search path, by adding it to
399    /// its own `__path__` in its `__init__.py`
400    /// ([`python_repeated_root_extension_dir`] names the directory that
401    /// statement has to name), and the statement has to be one the interpreter
402    /// runs and keeps: an append behind `if False:` and an append a later
403    /// `__path__ = ...` replaces are not that statement. With that statement,
404    /// and only with it, the declared root keeps admitting the spelling; the
405    /// module still has to be reached through a real package chain from the
406    /// implied root.
407    ///
408    /// Without a declaration the evidence has to be the snapshot's own package
409    /// chain: every directory from the implied root down to the candidate's own
410    /// directory must hold an `__init__.py`, so the specifier names a complete
411    /// package/module chain that starts at that root. This is what
412    /// `dependency/core/lib/common/__init__.py` has for `core.lib.common` when
413    /// `dependency/` is on `PYTHONPATH`, and what
414    /// `rtdetr_pose/rtdetr_pose/config.py` has for `rtdetr_pose.config` in a
415    /// project that declares no packages at all. A bare tail match with no
416    /// chain behind it is not proof of a root and never binds (#3506).
417    pub fn establishes_source_root(&self, candidate: &PythonSourceRootModule) -> bool {
418        if let Some(declared) = python_declared_import_root(&candidate.file) {
419            if declared == candidate.source_root {
420                return true;
421            }
422            let Some(extension) = python_repeated_root_extension_dir(
423                &candidate.file,
424                &candidate.source_root,
425                &declared,
426            ) else {
427                return false;
428            };
429            return self.package_chain_reaches_module(candidate)
430                && self
431                    .path_extensions
432                    .get(&candidate.source_root)
433                    .is_some_and(|extended| extended.contains(&extension));
434        }
435        self.package_chain_reaches_module(candidate)
436    }
437
438    /// Whether a complete `__init__.py` package chain runs from the candidate's
439    /// implied source root down to its own module.
440    ///
441    /// This is the snapshot's own evidence that the implied root is a root the
442    /// specifier can be written against: every directory from it down to the
443    /// file's directory is a package the workspace states, so the written name
444    /// runs through that package chain rather than through a coincidence of the
445    /// file's last path component.
446    fn package_chain_reaches_module(&self, candidate: &PythonSourceRootModule) -> bool {
447        debug_assert!(
448            candidate
449                .file
450                .rel_path()
451                .starts_with(&candidate.source_root),
452            "a candidate's implied root is a prefix of its own path"
453        );
454        let mut directory = candidate.file.rel_path().parent();
455        while let Some(current) = directory {
456            if !self.package_dirs.contains(current) {
457                return false;
458            }
459            if current == candidate.source_root {
460                return true;
461            }
462            directory = current.parent();
463        }
464        false
465    }
466}
467
468/// Whether `directory` is a package that holds a directory of its own name
469/// which is also a package.
470///
471/// This is the only layout a package marker has a repeated spelling to extend
472/// `__path__` for, and it is a name-only question the listing answers, so the
473/// markers whose source could state a path extension are the only ones ever
474/// read (#3506).
475fn python_package_holds_a_repeat_of_itself(
476    directory: &Path,
477    package_dirs: &HashSet<PathBuf>,
478) -> bool {
479    directory
480        .file_name()
481        .is_some_and(|name| package_dirs.contains(&directory.join(name)))
482}
483
484/// The project-relative directories the package marker `marker_rel_path` adds
485/// to its own `__path__` and keeps there for the imports that follow.
486///
487/// The pinned YOLOZU shim states
488/// `__path__.append(os.path.join(os.path.dirname(__file__), "rtdetr_pose"))`,
489/// behind `if _impl not in __path__:`, and that executed statement -- not the
490/// nested directory's repeated name -- is what makes `rtdetr_pose.config` name
491/// `rtdetr_pose/rtdetr_pose/config.py`. Every part is recovered from AST
492/// fields: a name is the assignment that bound it, `__file__` is the marker's
493/// own path, `os.path.dirname` takes the parent and `os.path.join` joins. A
494/// statement a later `__path__ = ...` replaces contributes nothing, and a
495/// statement no shape shows the interpreter runs contributes nothing either,
496/// so an unfamiliar spelling of the same idea is a missing positive rather
497/// than a wrong one.
498fn python_appended_path_dirs(source: &str, marker_rel_path: &Path) -> Vec<PathBuf> {
499    let Some(tree) = parse_python_tree(source) else {
500        return Vec::new();
501    };
502    let mut bound: HashMap<String, PathBuf> = HashMap::default();
503    bound.insert("__file__".to_owned(), marker_rel_path.to_path_buf());
504    let mut appended: Vec<PathBuf> = Vec::new();
505    walk_named_tree_preorder(tree.root_node(), true, |node| {
506        match node.kind() {
507            // Only module-level code runs the extension, so a name bound inside
508            // a function or class body says nothing about what `__path__`
509            // holds after the marker is executed.
510            "function_definition" | "class_definition" | "lambda" => {
511                return WalkControl::SkipChildren;
512            }
513            "assignment" => {
514                // A marker runs from the top, so a statement that assigns
515                // `__path__` throws away every directory appended before it:
516                // however many appends came first, `__path__ = [_base]` is the
517                // list the imports that follow will search.
518                if python_names_path(node.child_by_field_name("left"), source) {
519                    appended.clear();
520                }
521                python_bind_assignment(node, source, &mut bound);
522            }
523            "call" => {
524                let directories = python_path_appended_dirs_in_call(node, source, &bound);
525                if directories.is_empty()
526                    || !python_extension_statement_runs(node, source, &bound, &directories)
527                {
528                    return WalkControl::Continue;
529                }
530                for directory in directories {
531                    if directory.is_relative()
532                        && !directory.as_os_str().is_empty()
533                        && !appended.contains(&directory)
534                    {
535                        appended.push(directory);
536                    }
537                }
538            }
539            _ => {}
540        }
541        WalkControl::Continue
542    });
543    appended
544}
545
546/// Whether `node` -- when it is one -- is the marker's own `__path__` name.
547fn python_names_path(node: Option<Node<'_>>, source: &str) -> bool {
548    node.is_some_and(|node| {
549        node.kind() == "identifier" && node.utf8_text(source.as_bytes()).ok() == Some("__path__")
550    })
551}
552
553/// Whether the statement holding one `__path__` extension call is one the
554/// interpreter runs before the imports the marker serves.
555///
556/// Module-level statements run. Inside a branch they run only when the branch
557/// tests exactly the fact that makes the extension necessary: the pinned
558/// shim's membership guard `if _impl not in __path__:`, whose tested name
559/// denotes the directory the branch appends. Every other branch shape is not
560/// evidence -- `if False:` never runs the append, a predicate the reader
561/// cannot answer says nothing, and a loop, `try` or `else` body is no more
562/// proven than a comparison the guard does not state -- so the marker leaves
563/// an honest resolution gap rather than a binding. The same rule applies to
564/// the guard's own position: the append runs only when the guard does, so a
565/// guard that is itself inside another branch -- `if False:` around the pinned
566/// membership guard, say -- states nothing and leaves the gap (#3506).
567fn python_extension_statement_runs(
568    call: Node<'_>,
569    source: &str,
570    bound: &HashMap<String, PathBuf>,
571    directories: &[PathBuf],
572) -> bool {
573    let Some(statement) = call.parent() else {
574        return false;
575    };
576    if statement.kind() != "expression_statement" {
577        return false;
578    }
579    let Some(container) = statement.parent() else {
580        return false;
581    };
582    if container.kind() == "module" {
583        return true;
584    }
585    // The call's statement is a guarded body only when its own block is that
586    // `if`'s consequence: `else`, `elif`, loop, `try` and `with` bodies all
587    // share the block shape, and none of them is this guard. A one-line
588    // `if x: y()` puts the statement directly under the `if` instead.
589    let guarded = if container.kind() == "block" {
590        container.parent().filter(|parent| {
591            parent.kind() == "if_statement"
592                && parent.child_by_field_name("consequence") == Some(container)
593        })
594    } else if container.kind() == "if_statement"
595        && container.child_by_field_name("consequence") == Some(statement)
596    {
597        Some(container)
598    } else {
599        None
600    };
601    let Some(guarded) = guarded else {
602        return false;
603    };
604    // Execution certainty has to hold for the guard's ancestors too, and the
605    // only enclosing scope this reader can vouch for is the marker's own body:
606    // a guard under another branch, loop, `with` or `try` is no more proven
607    // than the shape that holds it.
608    if guarded.parent().map(|parent| parent.kind()) != Some("module") {
609        return false;
610    }
611    let Some(condition) = guarded.child_by_field_name("condition") else {
612        return false;
613    };
614    python_condition_justifies_path_extension(condition, source, bound, directories)
615}
616
617/// Whether a branch condition tests exactly the fact that makes the extension
618/// necessary: the directory the branch appends is the one missing from
619/// `__path__`, which is what the pinned shim's `if _impl not in __path__:`
620/// states.
621fn python_condition_justifies_path_extension(
622    condition: Node<'_>,
623    source: &str,
624    bound: &HashMap<String, PathBuf>,
625    directories: &[PathBuf],
626) -> bool {
627    if condition.kind() != "comparison_operator" {
628        return false;
629    }
630    let mut cursor = condition.walk();
631    let operators = condition
632        .children_by_field_name("operators", &mut cursor)
633        .map(|operator| operator.kind())
634        .collect::<Vec<_>>();
635    if operators.as_slice() != ["not in"] {
636        return false;
637    }
638    // The grammar gives a comparison no operand fields: its named children are
639    // the operands in source order.
640    let mut cursor = condition.walk();
641    let operands: Vec<Node<'_>> = condition.named_children(&mut cursor).collect();
642    let [left, right] = operands.as_slice() else {
643        return false;
644    };
645    if !python_names_path(Some(*right), source) {
646        return false;
647    }
648    python_path_expression_dir(*left, source, bound)
649        .is_some_and(|directory| directories.contains(&directory))
650}
651
652/// Bind the names one `name = value` statement assigns -- and every name of a
653/// chained `a = b = value` -- to the directory the value denotes. A value no
654/// shape answers leaves the name unbound, so a later use of it contributes no
655/// evidence either.
656fn python_bind_assignment(
657    assignment: Node<'_>,
658    source: &str,
659    bound: &mut HashMap<String, PathBuf>,
660) {
661    let mut targets: Vec<Node<'_>> = Vec::new();
662    let mut current = assignment;
663    loop {
664        let (Some(left), Some(right)) = (
665            current.child_by_field_name("left"),
666            current.child_by_field_name("right"),
667        ) else {
668            return;
669        };
670        targets.push(left);
671        if right.kind() != "assignment" {
672            let resolved = python_path_expression_dir(right, source, bound);
673            for target in targets {
674                if target.kind() != "identifier" {
675                    continue;
676                }
677                let Ok(name) = target.utf8_text(source.as_bytes()) else {
678                    continue;
679                };
680                match &resolved {
681                    Some(directory) => {
682                        bound.insert(name.to_owned(), directory.clone());
683                    }
684                    None => {
685                        bound.remove(name);
686                    }
687                }
688            }
689            return;
690        }
691        current = right;
692    }
693}
694
695/// The directories one `__path__.append(...)`, `__path__.insert(...)` or
696/// `__path__.extend(...)` call adds.
697///
698/// The call has to be a method of the marker's own `__path__`, which is the
699/// name the interpreter gives the search path it is building; a same-named
700/// attribute of anything else is not that list.
701fn python_path_appended_dirs_in_call(
702    call: Node<'_>,
703    source: &str,
704    bound: &HashMap<String, PathBuf>,
705) -> Vec<PathBuf> {
706    let Some(function) = call.child_by_field_name("function") else {
707        return Vec::new();
708    };
709    let Some(path) = python_static_attribute_path(function) else {
710        return Vec::new();
711    };
712    let [object, method] = path.as_slice() else {
713        return Vec::new();
714    };
715    if object.utf8_text(source.as_bytes()).ok() != Some("__path__") {
716        return Vec::new();
717    }
718    let Ok(method) = method.utf8_text(source.as_bytes()) else {
719        return Vec::new();
720    };
721    let Some(arguments) = call.child_by_field_name("arguments") else {
722        return Vec::new();
723    };
724    let mut cursor = arguments.walk();
725    let arguments: Vec<Node<'_>> = arguments.named_children(&mut cursor).collect();
726    let named: Vec<Node<'_>> = match method {
727        "append" => arguments.first().copied().into_iter().collect(),
728        "insert" => arguments.get(1).copied().into_iter().collect(),
729        "extend" => match arguments.first() {
730            Some(list) if list.kind() == "list" => {
731                let mut cursor = list.walk();
732                list.named_children(&mut cursor).collect()
733            }
734            Some(iterator) => vec![*iterator],
735            None => Vec::new(),
736        },
737        _ => Vec::new(),
738    };
739    named
740        .into_iter()
741        .filter_map(|node| python_path_expression_dir(node, source, bound))
742        .collect()
743}
744
745/// The project-relative directory a marker's own expression denotes under the
746/// names it has bound so far: a plain string literal, a bound name, or the
747/// `os.path` call that computes a directory from them.
748fn python_path_expression_dir(
749    node: Node<'_>,
750    source: &str,
751    bound: &HashMap<String, PathBuf>,
752) -> Option<PathBuf> {
753    if let Some(literal) = python_plain_string_literal(node, source) {
754        let directory = PathBuf::from(literal);
755        return (!directory.as_os_str().is_empty()).then_some(directory);
756    }
757    match node.kind() {
758        "identifier" => bound.get(node.utf8_text(source.as_bytes()).ok()?).cloned(),
759        "call" => python_path_call_dir(node, source, bound),
760        _ => None,
761    }
762}
763
764/// The directory one `os.path.dirname(...)` or `os.path.join(...)` call
765/// computes.
766fn python_path_call_dir(
767    call: Node<'_>,
768    source: &str,
769    bound: &HashMap<String, PathBuf>,
770) -> Option<PathBuf> {
771    let path = python_static_attribute_path(call.child_by_field_name("function")?)?;
772    let names: Vec<&str> = path
773        .iter()
774        .map(|node| node.utf8_text(source.as_bytes()).ok())
775        .collect::<Option<Vec<_>>>()?;
776    let arguments = call.child_by_field_name("arguments")?;
777    let mut cursor = arguments.walk();
778    let arguments: Vec<Node<'_>> = arguments.named_children(&mut cursor).collect();
779    match names.as_slice() {
780        ["os", "path", "dirname"] => {
781            let directory = python_path_expression_dir(*arguments.first()?, source, bound)?;
782            Some(directory.parent()?.to_path_buf())
783        }
784        ["os", "path", "join"] => {
785            let mut parts = arguments.iter();
786            let mut joined = python_path_expression_dir(*parts.next()?, source, bound)?;
787            for part in parts {
788                joined.push(python_path_expression_dir(*part, source, bound)?);
789            }
790            Some(joined)
791        }
792        _ => None,
793    }
794}
795
796/// The module `module_fq` names, as written in `importer`.
797///
798/// `importer` is `None` for an FQN-level question that no import statement
799/// asked -- a dotted name looked up on its own has no import root to be
800/// resolved against. With an importer the candidates are narrowed by
801/// [`retain_modules_for_importer`] first, and a specifier the index holds under
802/// no name at all falls through to [`module_under_source_root`].
803pub fn resolve_module_code_unit(
804    python: &dyn PythonSource,
805    importer: Option<&ProjectFile>,
806    module_fq: &str,
807) -> Option<CodeUnit> {
808    let indexed = match python.path_module_fqn(module_fq) {
809        // A path lookup that succeeds but finds no module unit is an answer:
810        // it does not fall through to the definition lookup.
811        Some(units) => indexed_module_for_importer(importer, units),
812        None => indexed_module_for_importer(importer, python.definition_fqn(module_fq)),
813    };
814    match indexed {
815        IndexedModuleLookup::Unique(module) => Some(module),
816        // The index spells the specifier more than once and the workspace does
817        // not say which of them the import means. A tail match elsewhere names
818        // a different module, so it cannot break this tie.
819        IndexedModuleLookup::Ambiguous => None,
820        IndexedModuleLookup::Missing => {
821            let importer = importer?;
822            module_under_source_root(&python.module_spellings(), importer, module_fq)
823        }
824    }
825}
826
827/// What the exact index says about a module specifier, before any source-root
828/// inference runs.
829///
830/// The three cases are kept apart because only one of them may fall through to
831/// the source-root candidates. A specifier the index spells exactly but several
832/// times is an ambiguity of identity (#3475): answering it with a suffix
833/// candidate that happens to end in the same name would replace a module the
834/// import names with an unrelated one the workspace never offered.
835enum IndexedModuleLookup {
836    Unique(CodeUnit),
837    Ambiguous,
838    Missing,
839}
840
841fn indexed_module_for_importer(
842    importer: Option<&ProjectFile>,
843    units: Vec<CodeUnit>,
844) -> IndexedModuleLookup {
845    let mut modules = units
846        .into_iter()
847        .filter(CodeUnit::is_module)
848        .collect::<Vec<_>>();
849    if let Some(importer) = importer {
850        retain_modules_for_importer(importer, &mut modules, CodeUnit::source);
851    }
852    match modules.len() {
853        0 => IndexedModuleLookup::Missing,
854        1 => IndexedModuleLookup::Unique(modules.remove(0)),
855        _ => IndexedModuleLookup::Ambiguous,
856    }
857}
858
859/// The file an absolute `module_fq` names when it is written against a source
860/// root the workspace's own structure establishes.
861///
862/// Python resolves an absolute import against the `sys.path` entries the
863/// importing program runs with. A workspace states the project root and its
864/// packaging roots, and [`python_import_root`] names modules from those; it does
865/// not state an entry only `PYTHONPATH` adds -- dayu's `dependency/`, whose
866/// `core.lib.common` is indexed as `dependency.core.lib.common` -- and a
867/// distribution directory holding a same-named package makes the inner package
868/// importable under both spellings, so YOLOZU's `rtdetr_pose/rtdetr_pose` is
869/// indexed as `rtdetr_pose.rtdetr_pose.config` and written as
870/// `rtdetr_pose.config` (#3506).
871///
872/// A tail match alone is not that evidence: `source/realpkg/vendor/api.py`
873/// ends in `vendor/api.py`, but under `where = ["source"]` it is
874/// `realpkg.vendor.api` and nothing else. What establishes a root is the
875/// snapshot's own structure -- a packaging manifest that declares it, or a
876/// complete `__init__.py` package chain from it down to the module -- and only
877/// a candidate whose implied root is established that way binds.
878/// [`PythonModuleSpellings::establishes_source_root`] is that decision; the
879/// candidates it rejects stay visible to the diagnostics as the spellings the
880/// workspace does index, which is a resolution gap and not externality.
881///
882/// Uniqueness is required after the same nearest-root narrowing an indexed
883/// module gets: two workspace copies of one module are an ambiguity the
884/// workspace does not resolve, and choosing between them here would invent a
885/// preference it never stated (#3475's rule, applied to the roots it cannot
886/// see).
887pub fn python_module_file_under_source_root(
888    spellings: &PythonModuleSpellings,
889    importer: &ProjectFile,
890    module_fq: &str,
891) -> Option<ProjectFile> {
892    let mut candidates = spellings.suffix_candidates(module_fq);
893    retain_nearest_owning_root(importer, &mut candidates, |candidate| {
894        candidate.source_root.clone()
895    });
896    candidates.retain(|candidate| spellings.establishes_source_root(candidate));
897    let [candidate] = candidates.as_slice() else {
898        return None;
899    };
900    debug_assert_eq!(
901        importer.root(),
902        candidate.file.root(),
903        "one workspace's Python files share one project root"
904    );
905    Some(candidate.file.clone())
906}
907
908/// [`python_module_file_under_source_root`] as the module unit the store-backed
909/// resolvers answer with. The unit is built from the file's own path, which is
910/// where its identity comes from, so it is the same unit the path-symbol index
911/// holds for that file.
912fn module_under_source_root(
913    spellings: &PythonModuleSpellings,
914    importer: &ProjectFile,
915    module_fq: &str,
916) -> Option<CodeUnit> {
917    let file = python_module_file_under_source_root(spellings, importer, module_fq)?;
918    module_code_unit(&file, &python_module_name(&file))
919}
920
921/// Batched sibling of `resolve_module_code_unit`: resolves every FQN's path-symbol lookup in one
922/// store transaction instead of one per FQN, then falls back to the (unbatched, rarer)
923/// definition-lookup path per FQN exactly as the single-FQN version does. Preserves its per-item
924/// semantics precisely, including that a path lookup which succeeds but finds no module unit does
925/// *not* fall through to the definition lookup.
926pub fn resolve_module_code_units_batch(
927    python: &dyn PythonSource,
928    importer: Option<&ProjectFile>,
929    module_fqs: &[String],
930) -> Vec<Option<CodeUnit>> {
931    let path_results = python.path_module_fqns_batch(module_fqs);
932    let mut lookups: Vec<IndexedModuleLookup> = Vec::with_capacity(module_fqs.len());
933    let mut needs_definition_fallback = Vec::new();
934    for (i, units) in path_results.into_iter().enumerate() {
935        match units {
936            Some(units) => lookups.push(indexed_module_for_importer(importer, units)),
937            None => {
938                needs_definition_fallback.push(i);
939                // Placeholder, replaced by the definition lookup below.
940                lookups.push(IndexedModuleLookup::Missing);
941            }
942        }
943    }
944    for i in needs_definition_fallback {
945        lookups[i] = indexed_module_for_importer(importer, python.definition_fqn(&module_fqs[i]));
946    }
947    let mut results: Vec<Option<CodeUnit>> = vec![None; module_fqs.len()];
948    let mut needs_source_root = Vec::new();
949    for (i, lookup) in lookups.into_iter().enumerate() {
950        match lookup {
951            IndexedModuleLookup::Unique(module) => results[i] = Some(module),
952            IndexedModuleLookup::Ambiguous => {}
953            IndexedModuleLookup::Missing => needs_source_root.push(i),
954        }
955    }
956    if let Some(importer) = importer
957        && !needs_source_root.is_empty()
958    {
959        // One memo handle for the whole batch: a workspace's external imports
960        // all miss the index, so this arm runs for most of the batch.
961        let spellings = python.module_spellings();
962        for i in needs_source_root {
963            results[i] = module_under_source_root(&spellings, importer, &module_fqs[i]);
964        }
965    }
966    results
967}
968
969pub fn compute_export_index_of(
970    python: &dyn PythonSource,
971    token: QueryToken<'_>,
972    file: &ProjectFile,
973) -> ExportIndex {
974    let mut index = ExportIndex::empty();
975    let mut events = Vec::new();
976    let declarations = python.top_level_declarations(file);
977    collect_local_export_events(
978        declarations.iter(),
979        |code_unit| {
980            python
981                .ranges(code_unit)
982                .iter()
983                .map(|range| range.start_byte)
984                .min()
985                .unwrap_or(usize::MAX)
986        },
987        &mut events,
988    );
989
990    if let Ok(source) = file.read_to_string()
991        && let Some(tree) = parse_python_tree(&source)
992    {
993        collect_reexport_events(
994            python,
995            file,
996            tree.root_node(),
997            &source,
998            &mut events,
999            &mut index,
1000        );
1001    } else {
1002        let imports = python.import_info_of(token, file);
1003        collect_reexport_events_from_imports(python, file, &imports, &mut events, &mut index);
1004    }
1005
1006    finish_export_index(events, index)
1007}
1008
1009pub fn export_index_from_file_facts(
1010    python: &dyn PythonSource,
1011    file: &ProjectFile,
1012    facts: &dyn IndexedFileFacts,
1013    module_name: &str,
1014    binder: &ImportBinder,
1015) -> ExportIndex {
1016    let mut index = ExportIndex::empty();
1017    let mut events = Vec::new();
1018    let mut local_names = collect_local_export_events(
1019        facts.top_level_declarations().iter(),
1020        |code_unit| {
1021            facts
1022                .declaration_ranges(code_unit)
1023                .into_iter()
1024                .flatten()
1025                .map(|range| range.start_byte)
1026                .min()
1027                .unwrap_or(usize::MAX)
1028        },
1029        &mut events,
1030    );
1031
1032    if !facts
1033        .top_level_declarations()
1034        .iter()
1035        .any(CodeUnit::is_module)
1036        && let Some(identifier) = module_name.rsplit('.').next()
1037        && !identifier.is_empty()
1038        && !identifier.starts_with('_')
1039    {
1040        local_names.insert(identifier.to_string());
1041        events.push((
1042            0,
1043            identifier.to_string(),
1044            ExportEntry::Local {
1045                local_name: identifier.to_string(),
1046            },
1047        ));
1048    }
1049
1050    if import_order_requires_source(binder, &local_names)
1051        && let Ok(source) = file.read_to_string()
1052        && let Some(tree) = parse_python_tree(&source)
1053    {
1054        collect_reexport_events(
1055            python,
1056            file,
1057            tree.root_node(),
1058            &source,
1059            &mut events,
1060            &mut index,
1061        );
1062    } else {
1063        collect_reexport_events_from_imports(
1064            python,
1065            file,
1066            facts.imports(),
1067            &mut events,
1068            &mut index,
1069        );
1070    }
1071
1072    finish_export_index(events, index)
1073}
1074
1075fn collect_local_export_events<'a>(
1076    declarations: impl IntoIterator<Item = &'a CodeUnit>,
1077    mut start_byte: impl FnMut(&CodeUnit) -> usize,
1078    events: &mut Vec<(usize, String, ExportEntry)>,
1079) -> HashSet<String> {
1080    let mut local_names = HashSet::default();
1081    for code_unit in declarations {
1082        let identifier = code_unit.identifier().trim();
1083        if identifier.is_empty() {
1084            continue;
1085        }
1086        local_names.insert(identifier.to_string());
1087        events.push((
1088            start_byte(code_unit),
1089            identifier.to_string(),
1090            ExportEntry::Local {
1091                local_name: identifier.to_string(),
1092            },
1093        ));
1094    }
1095    local_names
1096}
1097
1098fn finish_export_index(
1099    mut events: Vec<(usize, String, ExportEntry)>,
1100    mut index: ExportIndex,
1101) -> ExportIndex {
1102    events.sort_by_key(|(start_byte, _, _)| *start_byte);
1103    for (_, exported_name, entry) in events {
1104        index.exports_by_name.insert(exported_name, entry);
1105    }
1106    index
1107}
1108
1109fn collect_reexport_events(
1110    python: &dyn PythonSource,
1111    file: &ProjectFile,
1112    root: tree_sitter::Node<'_>,
1113    source: &str,
1114    events: &mut Vec<(usize, String, ExportEntry)>,
1115    index: &mut ExportIndex,
1116) {
1117    // Module scope is not depth one. A `from ... import` inside an if/else,
1118    // try/except, with or match block still binds a module-level name, so it
1119    // re-exports like any other (issue #1764). Only a function or class body
1120    // opens a scope whose bindings are not module exports.
1121    let mut stack = vec![root];
1122    while let Some(node) = stack.pop() {
1123        let mut cursor = node.walk();
1124        for child in node.named_children(&mut cursor) {
1125            match child.kind() {
1126                "import_from_statement" => {
1127                    for info in python_import_infos_from_node(child, source) {
1128                        record_single_reexport_event(python, file, &info, events, index);
1129                    }
1130                }
1131                "function_definition" | "class_definition" => {}
1132                _ => stack.push(child),
1133            }
1134        }
1135    }
1136}
1137
1138fn collect_reexport_events_from_imports(
1139    python: &dyn PythonSource,
1140    file: &ProjectFile,
1141    imports: &[ImportInfo],
1142    events: &mut Vec<(usize, String, ExportEntry)>,
1143    index: &mut ExportIndex,
1144) {
1145    for import in imports {
1146        record_single_reexport_event(python, file, import, events, index);
1147    }
1148}
1149
1150fn record_single_reexport_event(
1151    python: &dyn PythonSource,
1152    file: &ProjectFile,
1153    import: &ImportInfo,
1154    events: &mut Vec<(usize, String, ExportEntry)>,
1155    index: &mut ExportIndex,
1156) {
1157    let Some(PythonImportDetails::FromImport {
1158        module,
1159        name,
1160        alias,
1161        wildcard,
1162    }) = python_import_details(import)
1163    else {
1164        return;
1165    };
1166    let start_byte = import
1167        .path
1168        .as_ref()
1169        .map(|path| path.declaration_start_byte)
1170        .unwrap_or(usize::MAX);
1171    let resolved_module = if module.starts_with('.') {
1172        resolve_python_relative_module(file, &module)
1173    } else {
1174        Some(module.clone())
1175    };
1176    let Some(resolved_module) = resolved_module else {
1177        return;
1178    };
1179
1180    if wildcard {
1181        index.reexport_stars.push(ReexportStar {
1182            module_specifier: resolved_module,
1183        });
1184        return;
1185    }
1186    let exported_name = alias.unwrap_or(name.clone());
1187    // `from P import S` binds the submodule `P.S` itself when that module
1188    // exists, exactly as the import binder reads it below. Recording it as
1189    // "the name S inside module P.S" would follow the subpackage's own
1190    // exports, which silently mis-resolves whenever the subpackage re-exports
1191    // a member named after itself (issue #1762).
1192    let module_candidate = format!("{resolved_module}.{name}");
1193    if resolve_module_code_unit(python, Some(file), &module_candidate).is_some() {
1194        events.push((
1195            start_byte,
1196            exported_name,
1197            ExportEntry::ReexportedModule {
1198                module_specifier: module_candidate,
1199            },
1200        ));
1201        return;
1202    }
1203    events.push((
1204        start_byte,
1205        exported_name,
1206        ExportEntry::ReexportedNamed {
1207            module_specifier: resolved_module,
1208            imported_name: name,
1209        },
1210    ));
1211}
1212
1213pub fn import_binder_from_imports(
1214    python: &dyn PythonSource,
1215    file: &ProjectFile,
1216    imports: &[ImportInfo],
1217) -> ImportBinder {
1218    let mut binder = ImportBinder::empty();
1219
1220    for (local_name, binding) in import_bindings_from_imports(python, file, imports) {
1221        binder.bindings.insert(local_name, binding);
1222    }
1223
1224    binder
1225}
1226
1227/// Resolve each structured import without collapsing repeated local names.
1228///
1229/// Candidate discovery needs every lexical binding. The ordinary binder keeps
1230/// one effective binding for simple lookups.
1231pub fn import_bindings_from_imports(
1232    python: &dyn PythonSource,
1233    file: &ProjectFile,
1234    imports: &[ImportInfo],
1235) -> Vec<(String, ImportBinding)> {
1236    let mut bindings = Vec::new();
1237
1238    for import in imports {
1239        let Some(details) = python_import_details(import) else {
1240            continue;
1241        };
1242        match details {
1243            PythonImportDetails::Import { module, alias } => {
1244                let local_name = python_namespace_binding_name(import, alias.as_deref(), &module);
1245                let module_specifier =
1246                    python_namespace_binding_module(import, alias.as_deref(), &module);
1247                bindings.push((
1248                    local_name,
1249                    ImportBinding {
1250                        module_specifier,
1251                        namespace_imported_module: Some(module),
1252                        kind: ImportKind::Namespace,
1253                        imported_name: None,
1254                    },
1255                ));
1256            }
1257            PythonImportDetails::FromImport {
1258                module,
1259                name,
1260                wildcard,
1261                ..
1262            } => {
1263                let resolved_module = if module.starts_with('.') {
1264                    resolve_python_relative_module(file, &module)
1265                } else {
1266                    Some(module.clone())
1267                };
1268                let Some(resolved_module) = resolved_module else {
1269                    continue;
1270                };
1271                if wildcard {
1272                    // A glob import introduces each public declaration as a
1273                    // real local binding. Expand it from the structured module
1274                    // declarations so constructor and receiver inference can
1275                    // resolve the same names Python places in the namespace.
1276                    bindings.extend(
1277                        public_declarations_in_module(python, file, &resolved_module)
1278                            .into_iter()
1279                            .map(|declaration| {
1280                                let name = declaration.identifier().to_string();
1281                                (
1282                                    name.clone(),
1283                                    ImportBinding {
1284                                        module_specifier: resolved_module.clone(),
1285                                        namespace_imported_module: None,
1286                                        kind: ImportKind::Named,
1287                                        imported_name: Some(name),
1288                                    },
1289                                )
1290                            }),
1291                    );
1292                    continue;
1293                }
1294                // Non-wildcard from-imports always populate `identifier`
1295                // as `alias ?? name` (see `python_import_details`), so
1296                // `local_name()` reproduces the same alias-first fallback
1297                // without re-deriving it here.
1298                let local_name = import
1299                    .local_name()
1300                    .map(str::to_string)
1301                    .unwrap_or_else(|| name.clone());
1302                let module_candidate = format!("{resolved_module}.{name}");
1303                if resolve_module_code_unit(python, Some(file), &module_candidate).is_some() {
1304                    bindings.push((
1305                        local_name,
1306                        ImportBinding {
1307                            module_specifier: module_candidate,
1308                            namespace_imported_module: None,
1309                            kind: ImportKind::Namespace,
1310                            imported_name: None,
1311                        },
1312                    ));
1313                    continue;
1314                }
1315                bindings.push((
1316                    local_name,
1317                    ImportBinding {
1318                        module_specifier: resolved_module,
1319                        namespace_imported_module: None,
1320                        kind: ImportKind::Named,
1321                        imported_name: Some(name),
1322                    },
1323                ));
1324            }
1325        }
1326    }
1327
1328    bindings
1329}
1330
1331pub fn public_declarations_in_module(
1332    python: &dyn PythonSource,
1333    importer: &ProjectFile,
1334    module_fq: &str,
1335) -> Vec<CodeUnit> {
1336    let Some(module_code_unit) = resolve_module_code_unit(python, Some(importer), module_fq) else {
1337        return Vec::new();
1338    };
1339    python
1340        .direct_children(&module_code_unit)
1341        .into_iter()
1342        .filter(|code_unit| !code_unit.identifier().starts_with('_'))
1343        .collect()
1344}
1345
1346pub fn resolve_base_class(
1347    python: &dyn PythonSource,
1348    token: QueryToken<'_>,
1349    code_unit: &CodeUnit,
1350    raw: &str,
1351) -> Option<CodeUnit> {
1352    let trimmed = raw.trim();
1353    if trimmed.is_empty() {
1354        return None;
1355    }
1356
1357    let binder = python.import_binder_of(code_unit.source());
1358    if let Some((head, tail)) = trimmed.split_once('.') {
1359        if let Some(binding) = binder.bindings.get(head)
1360            && binding.kind == ImportKind::Namespace
1361        {
1362            let fq_name = format!("{}.{}", binding.module_specifier, tail);
1363            return python.definitions(&fq_name).next();
1364        }
1365        return python.definitions(trimmed).next();
1366    }
1367
1368    if let Some(binding) = binder.bindings.get(trimmed) {
1369        match binding.kind {
1370            ImportKind::Namespace => {
1371                return resolve_module_code_unit(
1372                    python,
1373                    Some(code_unit.source()),
1374                    &binding.module_specifier,
1375                );
1376            }
1377            ImportKind::Named => {
1378                let imported_name = binding.imported_name.as_ref()?;
1379                let fqn = format!("{}.{}", binding.module_specifier, imported_name);
1380                return resolve_exported_fqn(python, Some(code_unit.source()), &fqn)
1381                    .into_iter()
1382                    .next()
1383                    .or_else(|| python.definitions(&fqn).next());
1384            }
1385            _ => {}
1386        }
1387    }
1388
1389    if python
1390        .import_info_of(token, code_unit.source())
1391        .iter()
1392        .any(|import| import.is_wildcard)
1393        && let Some(imported) =
1394            resolve_import_bindings(python, token, code_unit.source()).get(trimmed)
1395    {
1396        return Some(imported.clone());
1397    }
1398
1399    let local_fq_name = format!("{}.{}", code_unit.package_name(), trimmed);
1400    python
1401        .definitions(&local_fq_name)
1402        .next()
1403        .or_else(|| python.definitions(trimmed).next())
1404}
1405
1406pub fn render_skeleton_recursive(
1407    index: &dyn CodeUnitIndex,
1408    code_unit: &CodeUnit,
1409    indent: &str,
1410    header_only: bool,
1411    out: &mut String,
1412) {
1413    if let Some(signature) = python_signature(index, code_unit, header_only) {
1414        for line in signature.lines() {
1415            out.push_str(indent);
1416            out.push_str(line);
1417            out.push('\n');
1418        }
1419    }
1420
1421    let all_children = index.direct_children(code_unit);
1422    let field_children: Vec<_> = all_children
1423        .iter()
1424        .filter(|child| child.is_field())
1425        .cloned()
1426        .collect();
1427    let children = if header_only {
1428        field_children.clone()
1429    } else {
1430        all_children.clone()
1431    };
1432    if !children.is_empty() || code_unit.is_class() || code_unit.is_module() {
1433        let child_indent = format!("{indent}  ");
1434        for child in children {
1435            render_skeleton_recursive(index, &child, &child_indent, header_only, out);
1436        }
1437        if header_only && all_children.len() > field_children.len() {
1438            out.push_str(&child_indent);
1439            out.push_str("[...]\n");
1440        }
1441    }
1442}
1443
1444fn python_signature(
1445    index: &dyn CodeUnitIndex,
1446    code_unit: &CodeUnit,
1447    _header_only: bool,
1448) -> Option<String> {
1449    if code_unit.is_module() {
1450        return None;
1451    }
1452
1453    let source = index.get_source(code_unit, false)?;
1454    let lines: Vec<_> = source
1455        .lines()
1456        .map(str::trim_end)
1457        .filter(|line| !line.trim().is_empty())
1458        .collect();
1459    if lines.is_empty() {
1460        return None;
1461    }
1462
1463    let mut decorators = Vec::new();
1464    let mut header = None;
1465    for line in lines {
1466        let trimmed = line.trim_start();
1467        if trimmed.starts_with('@') {
1468            decorators.push(trimmed.to_string());
1469            continue;
1470        }
1471        header = Some(trimmed.to_string());
1472        break;
1473    }
1474    let mut rendered = String::new();
1475    for decorator in decorators {
1476        rendered.push_str(&decorator);
1477        rendered.push('\n');
1478    }
1479
1480    let header = header?;
1481    match code_unit.kind() {
1482        CodeUnitType::Class => rendered.push_str(&header),
1483        CodeUnitType::Function => {
1484            rendered.push_str(header.trim_end_matches(':'));
1485            rendered.push_str(": ...");
1486        }
1487        CodeUnitType::Field | CodeUnitType::Macro => rendered.push_str(header.as_str()),
1488        CodeUnitType::Module | CodeUnitType::FileScope => return None,
1489    }
1490    Some(rendered)
1491}
1492
1493fn import_order_requires_source(binder: &ImportBinder, local_names: &HashSet<String>) -> bool {
1494    binder
1495        .bindings
1496        .keys()
1497        .any(|bound_name| local_names.contains(bound_name))
1498}