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