Skip to main content

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