Skip to main content

brokk_bifrost_python/
imports.rs

1//! Python import syntax, binding, and FQN resolution.
2//!
3//! Everything here is a free function over [`PythonSource`]; the
4//! `ImportAnalysisProvider` impl that memoizes the per-file results stays on
5//! `PythonAnalyzer` in `analyzer/python/imports.rs`.
6
7use crate::bindings::python_direct_scope_bindings_bounded;
8use crate::declarations::{parse_python_tree, py_node_text, python_module_name};
9use crate::graph_support::{
10    PythonSource, import_binder_from_imports, public_declarations_in_module,
11    resolve_module_code_unit, resolve_module_code_units_batch,
12};
13use brokk_bifrost_core::analyzer::common::node_source_text;
14use brokk_bifrost_core::analyzer::model::{
15    ImportInfo, StructuredImportPath, StructuredImportPathKind,
16};
17use brokk_bifrost_core::analyzer::query_token::QueryToken;
18use brokk_bifrost_core::analyzer::usages::model::{ExportEntry, ImportBinding, ImportKind};
19use brokk_bifrost_core::analyzer::{CodeUnit, CodeUnitIndex, ProjectFile};
20use brokk_bifrost_core::hash::{HashMap, HashSet};
21use std::collections::VecDeque;
22use tree_sitter::Node;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct PythonModuleReplacement {
26    pub target_module: String,
27}
28
29/// Return the absolute module named by the last direct assignment to `local`
30/// that dominates `reference` when that assignment has the exact structured
31/// shape `local = importlib_binding.import_module("module.name")`.
32///
33/// Assignments hidden in a branch do not dominate the reference. Any later
34/// direct binding of `local` invalidates an earlier result. The caller proves
35/// that the receiver of `import_module` is an in-scope namespace binding for
36/// Python's `importlib` module; this helper only interprets the syntax and
37/// statement order.
38pub(crate) fn imported_module_assignment_at(
39    reference: Node<'_>,
40    local: &str,
41    source: &str,
42    mut is_importlib_binding: impl FnMut(&str) -> bool,
43) -> Option<String> {
44    let suite = enclosing_execution_suite(reference)?;
45    let mut resolved = None;
46    let mut cursor = suite.walk();
47    for statement in suite.named_children(&mut cursor) {
48        if statement.start_byte() >= reference.start_byte()
49            || (statement.start_byte() <= reference.start_byte()
50                && reference.end_byte() <= statement.end_byte())
51        {
52            break;
53        }
54
55        let expression = if statement.kind() == "expression_statement" {
56            statement.named_child(0).unwrap_or(statement)
57        } else {
58            statement
59        };
60        if expression.kind() == "assignment"
61            && expression.child_by_field_name("left").is_some_and(|left| {
62                left.kind() == "identifier" && node_source_text(left, source) == local
63            })
64        {
65            resolved = expression
66                .child_by_field_name("right")
67                .and_then(|right| importlib_call_module(right, source, &mut is_importlib_binding));
68            continue;
69        }
70
71        let binds_local = python_direct_scope_bindings_bounded(statement, source, || true)
72            .expect("unbounded binding collection cannot be cancelled")
73            .into_iter()
74            .any(|binding| node_source_text(binding.declaration, source) == local);
75        if binds_local {
76            resolved = None;
77        }
78    }
79    resolved
80}
81
82fn enclosing_execution_suite(mut node: Node<'_>) -> Option<Node<'_>> {
83    while let Some(parent) = node.parent() {
84        if matches!(parent.kind(), "function_definition" | "lambda") {
85            return parent.child_by_field_name("body");
86        }
87        if parent.kind() == "module" {
88            return Some(parent);
89        }
90        node = parent;
91    }
92    None
93}
94
95fn importlib_call_module(
96    call: Node<'_>,
97    source: &str,
98    is_importlib_binding: &mut impl FnMut(&str) -> bool,
99) -> Option<String> {
100    if call.kind() != "call" {
101        return None;
102    }
103    let function = call.child_by_field_name("function")?;
104    if function.kind() != "attribute" {
105        return None;
106    }
107    let (object, attribute) = (
108        function.child_by_field_name("object")?,
109        function.child_by_field_name("attribute")?,
110    );
111    if object.kind() != "identifier"
112        || attribute.kind() != "identifier"
113        || node_source_text(attribute, source) != "import_module"
114        || !is_importlib_binding(node_source_text(object, source))
115    {
116        return None;
117    }
118
119    let arguments = call.child_by_field_name("arguments")?;
120    if arguments.named_child_count() != 1 {
121        return None;
122    }
123    let literal = arguments.named_child(0)?;
124    if literal.kind() != "string"
125        || literal
126            .parent()
127            .is_some_and(|parent| parent.kind() == "concatenated_string")
128    {
129        return None;
130    }
131    let mut content = None;
132    for index in 0..literal.named_child_count() {
133        let child = literal.named_child(index)?;
134        match child.kind() {
135            "string_start" | "string_end" => {}
136            "string_content" if content.is_none() && child.named_child_count() == 0 => {
137                content = Some(child);
138            }
139            _ => return None,
140        }
141    }
142    let content = content?;
143    let module = node_source_text(content, source);
144    (!module.is_empty() && !module.starts_with('.')).then(|| module.to_string())
145}
146
147/// Collect absolute module names from structurally exact
148/// `local = importlib.import_module("...")` assignments. This is a conservative
149/// candidate-file index: dominance and local shadowing are checked later at the
150/// reference site by [`imported_module_assignment_at`].
151pub(crate) fn literal_importlib_modules(
152    source: &str,
153    bindings: &HashMap<String, ImportBinding>,
154) -> HashSet<String> {
155    let Some(tree) = parse_python_tree(source) else {
156        return HashSet::default();
157    };
158    let mut modules = HashSet::default();
159    let mut stack = vec![tree.root_node()];
160    while let Some(node) = stack.pop() {
161        if node.kind() == "assignment"
162            && node
163                .child_by_field_name("left")
164                .is_some_and(|left| left.kind() == "identifier")
165            && let Some(module) = node.child_by_field_name("right").and_then(|right| {
166                importlib_call_module(right, source, &mut |local| {
167                    bindings.get(local).is_some_and(|binding| {
168                        binding.kind == ImportKind::Namespace
169                            && binding
170                                .namespace_imported_module
171                                .as_deref()
172                                .unwrap_or(&binding.module_specifier)
173                                == "importlib"
174                    })
175                })
176            })
177        {
178            modules.insert(module);
179        }
180        let mut cursor = node.walk();
181        stack.extend(node.named_children(&mut cursor));
182    }
183    modules
184}
185
186/// Recognize the compatibility-shim idiom that replaces the current module
187/// object with an imported workspace module:
188///
189/// `sys.modules[__name__] = imported_module`
190///
191/// Every component is resolved from tree-sitter fields and the import binder;
192/// similarly spelled attributes or locals do not create an alias edge.
193fn module_replacement_from_assignment(
194    assignment: Node<'_>,
195    source: &str,
196    bindings: &HashMap<String, ImportBinding>,
197) -> Option<PythonModuleReplacement> {
198    let (left, right) = (
199        assignment.child_by_field_name("left")?,
200        assignment.child_by_field_name("right")?,
201    );
202    if left.kind() != "subscript" || right.kind() != "identifier" {
203        return None;
204    }
205    let (value, subscript) = (
206        left.child_by_field_name("value")?,
207        left.child_by_field_name("subscript")?,
208    );
209    if value.kind() != "attribute"
210        || subscript.kind() != "identifier"
211        || node_source_text(subscript, source) != "__name__"
212    {
213        return None;
214    }
215    let (sys_local, modules) = (
216        value.child_by_field_name("object")?,
217        value.child_by_field_name("attribute")?,
218    );
219    if sys_local.kind() != "identifier"
220        || modules.kind() != "identifier"
221        || node_source_text(modules, source) != "modules"
222    {
223        return None;
224    }
225    let sys_binding = bindings.get(node_source_text(sys_local, source))?;
226    let sys_module = sys_binding
227        .namespace_imported_module
228        .as_deref()
229        .unwrap_or(&sys_binding.module_specifier);
230    if sys_binding.kind != ImportKind::Namespace || sys_module != "sys" {
231        return None;
232    }
233
234    let target_binding = bindings.get(node_source_text(right, source))?;
235    if target_binding.kind != ImportKind::Namespace {
236        return None;
237    }
238    Some(PythonModuleReplacement {
239        target_module: target_binding
240            .namespace_imported_module
241            .clone()
242            .unwrap_or_else(|| target_binding.module_specifier.clone()),
243    })
244}
245
246fn remove_direct_scope_bindings(
247    statement: Node<'_>,
248    source: &str,
249    bindings: &mut HashMap<String, ImportBinding>,
250) {
251    let mut stack = vec![statement];
252    while let Some(node) = stack.pop() {
253        for binding in python_direct_scope_bindings_bounded(node, source, || true)
254            .expect("unbounded binding collection cannot be cancelled")
255        {
256            bindings.remove(node_source_text(binding.declaration, source));
257        }
258        if matches!(
259            node.kind(),
260            "function_definition" | "class_definition" | "lambda"
261        ) {
262            continue;
263        }
264        let mut cursor = node.walk();
265        stack.extend(node.named_children(&mut cursor));
266    }
267}
268
269/// Parse the structured import facts for a Python document without executing
270/// it. LSP model binding uses the same AST-derived representation as the
271/// analyzer so external APIs are never selected by a terminal-name scan.
272pub fn parse_python_import_infos(source: &str) -> Vec<ImportInfo> {
273    let mut parser = tree_sitter::Parser::new();
274    parser
275        .set_language(&tree_sitter_python::LANGUAGE.into())
276        .expect("failed to load Python parser");
277    let Some(tree) = parser.parse(source, None) else {
278        return Vec::new();
279    };
280    let mut pending = vec![tree.root_node()];
281    let mut imports = Vec::new();
282    while let Some(node) = pending.pop() {
283        if matches!(node.kind(), "import_statement" | "import_from_statement") {
284            imports.extend(python_import_infos_from_node(node, source));
285            continue;
286        }
287        let mut cursor = node.walk();
288        pending.extend(node.named_children(&mut cursor));
289    }
290    imports
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct PythonImportBinding {
295    pub start_byte: usize,
296    pub scope_start_byte: usize,
297    pub scope_end_byte: usize,
298    function_scoped: bool,
299    pub local_name: String,
300    pub qualified_name: String,
301    pub consumed_attributes: usize,
302}
303
304impl PythonImportBinding {
305    pub fn is_function_scoped(&self) -> bool {
306        self.function_scoped
307    }
308}
309
310/// Return source-ordered, parser-derived local bindings for explicit Python
311/// imports. The byte offset lets callers select the last visible binding rather
312/// than treating a whole document as one unordered import scope.
313pub fn parse_python_import_bindings(source: &str) -> Vec<PythonImportBinding> {
314    let mut parser = tree_sitter::Parser::new();
315    parser
316        .set_language(&tree_sitter_python::LANGUAGE.into())
317        .expect("failed to load Python parser");
318    let Some(tree) = parser.parse(source, None) else {
319        return Vec::new();
320    };
321    python_import_bindings_from_tree(tree.root_node(), source)
322}
323
324/// Return structured import bindings from a tree the caller already owns.
325/// Query paths use this form so one request never reparses a document merely
326/// to recover function-local import scope.
327pub fn python_import_bindings_from_tree(root: Node<'_>, source: &str) -> Vec<PythonImportBinding> {
328    let mut pending = vec![root];
329    let mut nodes = Vec::new();
330    while let Some(node) = pending.pop() {
331        if matches!(node.kind(), "import_statement" | "import_from_statement") {
332            nodes.push(node);
333            continue;
334        }
335        let mut cursor = node.walk();
336        pending.extend(node.named_children(&mut cursor));
337    }
338    nodes.sort_by_key(Node::start_byte);
339    nodes
340        .into_iter()
341        .flat_map(|node| {
342            let (scope_start_byte, scope_end_byte, function_scoped) =
343                python_import_binding_scope(node, source.len());
344            python_import_infos_from_node(node, source)
345                .into_iter()
346                .filter_map(move |import| {
347                    let path = import.path.as_ref()?;
348                    let details = python_import_details(&import)?;
349                    match details {
350                        PythonImportDetails::Import { module, alias } => {
351                            let consumed_attributes = if alias.is_some() {
352                                0
353                            } else {
354                                path.segments.len().saturating_sub(1)
355                            };
356                            Some(PythonImportBinding {
357                                start_byte: node.start_byte(),
358                                scope_start_byte,
359                                scope_end_byte,
360                                function_scoped,
361                                local_name: alias.or_else(|| path.segments.first().cloned())?,
362                                qualified_name: module,
363                                consumed_attributes,
364                            })
365                        }
366                        PythonImportDetails::FromImport {
367                            module,
368                            name,
369                            alias,
370                            wildcard: false,
371                        } => Some(PythonImportBinding {
372                            start_byte: node.start_byte(),
373                            scope_start_byte,
374                            scope_end_byte,
375                            function_scoped,
376                            local_name: alias.unwrap_or(name.clone()),
377                            qualified_name: format!("{module}.{name}"),
378                            consumed_attributes: 0,
379                        }),
380                        PythonImportDetails::FromImport { wildcard: true, .. } => None,
381                    }
382                })
383        })
384        .collect()
385}
386
387fn python_import_binding_scope(node: Node<'_>, source_len: usize) -> (usize, usize, bool) {
388    let mut parent = node.parent();
389    while let Some(scope) = parent {
390        if matches!(scope.kind(), "function_definition" | "lambda") {
391            return (scope.start_byte(), scope.end_byte(), true);
392        }
393        parent = scope.parent();
394    }
395    (0, source_len, false)
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use brokk_bifrost_core::analyzer::usages::model::ImportBinder;
402
403    fn importlib_assignment_for(source: &str, importlib_local: &str) -> Option<String> {
404        let tree = parse_python_tree(source).expect("valid Python fixture");
405        let reference_start = source.rfind("canonical.setup").expect("reference site");
406        let mut stack = vec![tree.root_node()];
407        let mut reference = None;
408        while let Some(node) = stack.pop() {
409            if node.kind() == "identifier"
410                && node.start_byte() == reference_start
411                && node_source_text(node, source) == "canonical"
412            {
413                reference = Some(node);
414                break;
415            }
416            let mut cursor = node.walk();
417            stack.extend(node.named_children(&mut cursor));
418        }
419        imported_module_assignment_at(
420            reference.expect("canonical reference node"),
421            "canonical",
422            source,
423            |local| local == importlib_local,
424        )
425    }
426
427    #[test]
428    fn existing_tree_import_bindings_preserve_function_scope_and_relative_identity() {
429        let source = r#"def register():
430    from .workers.special import Worker
431    return Worker()
432"#;
433        let tree = parse_python_tree(source).expect("Python tree");
434        let bindings = python_import_bindings_from_tree(tree.root_node(), source);
435
436        assert_eq!(bindings.len(), 1, "{bindings:#?}");
437        assert!(bindings[0].is_function_scoped(), "{bindings:#?}");
438        assert_eq!(bindings[0].local_name, "Worker");
439        assert_eq!(bindings[0].qualified_name, ".workers.special.Worker");
440        assert!(bindings[0].scope_start_byte <= bindings[0].start_byte);
441        assert!(bindings[0].scope_end_byte >= source.rfind("Worker()").unwrap());
442    }
443
444    #[test]
445    fn importlib_assignment_requires_a_direct_literal_dominating_write() {
446        let valid = r#"import importlib as loader
447
448def test():
449    canonical = loader.import_module("routes.contacts.contacts_routes")
450    assert canonical.setup_contacts_routes
451"#;
452        assert_eq!(
453            importlib_assignment_for(valid, "loader"),
454            Some("routes.contacts.contacts_routes".to_string())
455        );
456
457        for near_miss in [
458            r#"import importlib as loader
459def test(module_name):
460    canonical = loader.import_module(module_name)
461    assert canonical.setup_contacts_routes
462"#,
463            r#"import importlib as loader
464def test():
465    if enabled:
466        canonical = loader.import_module("routes.contacts.contacts_routes")
467    assert canonical.setup_contacts_routes
468"#,
469            r#"import importlib as loader
470def test():
471    canonical = loader.import_module("routes.contacts.contacts_routes")
472    canonical = object()
473    assert canonical.setup_contacts_routes
474"#,
475        ] {
476            assert_eq!(
477                importlib_assignment_for(near_miss, "loader"),
478                None,
479                "near miss must not infer a module receiver: {near_miss:?}"
480            );
481        }
482        assert_eq!(
483            importlib_assignment_for(valid, "shadowed_loader"),
484            None,
485            "a similarly shaped non-importlib receiver must not resolve"
486        );
487    }
488
489    fn replacement_for(source: &str, binder: &ImportBinder) -> Option<PythonModuleReplacement> {
490        let tree = parse_python_tree(source).expect("valid Python fixture");
491        let root = tree.root_node();
492        let mut replacement = None;
493        let mut cursor = root.walk();
494        for statement in root.named_children(&mut cursor) {
495            let statement = if statement.kind() == "expression_statement" {
496                statement.named_child(0).expect("fixture expression")
497            } else {
498                statement
499            };
500            if statement.kind() == "assignment"
501                && let Some(next) =
502                    module_replacement_from_assignment(statement, source, &binder.bindings)
503                && replacement.replace(next).is_some()
504            {
505                return None;
506            }
507        }
508        replacement
509    }
510
511    #[test]
512    fn module_replacement_requires_exact_structured_import_bindings() {
513        let source = r#"import sys as _sys
514from routes.contacts import contacts_routes as _canonical
515
516_sys.modules[__name__] = _canonical
517"#;
518        let mut binder = ImportBinder::empty();
519        binder.bindings.insert(
520            "_sys".to_string(),
521            ImportBinding {
522                module_specifier: "sys".to_string(),
523                namespace_imported_module: Some("sys".to_string()),
524                kind: ImportKind::Namespace,
525                imported_name: None,
526            },
527        );
528        binder.bindings.insert(
529            "_canonical".to_string(),
530            ImportBinding {
531                module_specifier: "routes.contacts.contacts_routes".to_string(),
532                namespace_imported_module: None,
533                kind: ImportKind::Namespace,
534                imported_name: None,
535            },
536        );
537
538        assert_eq!(
539            replacement_for(source, &binder),
540            Some(PythonModuleReplacement {
541                target_module: "routes.contacts.contacts_routes".to_string(),
542            })
543        );
544
545        for near_miss in [
546            "cache.modules[__name__] = _canonical\n",
547            "_sys.modules[module_name] = _canonical\n",
548            "_sys.modules[__name__] = build()\n",
549            "def replace():\n    _sys.modules[__name__] = _canonical\n",
550        ] {
551            assert_eq!(
552                replacement_for(near_miss, &binder),
553                None,
554                "near miss must not replace module identity: {near_miss:?}"
555            );
556        }
557    }
558}
559
560pub fn module_replacement_of(
561    python: &dyn PythonSource,
562    file: &ProjectFile,
563    source: &str,
564) -> Option<PythonModuleReplacement> {
565    let tree = parse_python_tree(source)?;
566    let root = tree.root_node();
567    let mut bindings: HashMap<String, ImportBinding> = HashMap::default();
568    let mut replacement = None;
569    let mut cursor = root.walk();
570    for statement in root.named_children(&mut cursor) {
571        let statement = if statement.kind() == "expression_statement" {
572            let Some(expression) = statement.named_child(0) else {
573                continue;
574            };
575            expression
576        } else {
577            statement
578        };
579        match statement.kind() {
580            "import_statement" | "import_from_statement" => {
581                let imports = python_import_infos_from_node(statement, source);
582                bindings.extend(import_binder_from_imports(python, file, &imports).bindings);
583            }
584            "assignment" => {
585                if let Some(next) = module_replacement_from_assignment(statement, source, &bindings)
586                    && replacement.replace(next).is_some()
587                {
588                    return None;
589                }
590                remove_direct_scope_bindings(statement, source, &mut bindings);
591            }
592            _ => remove_direct_scope_bindings(statement, source, &mut bindings),
593        }
594    }
595    replacement
596}
597
598pub fn resolve_import_bindings(
599    python: &dyn PythonSource,
600    token: QueryToken<'_>,
601    file: &ProjectFile,
602) -> HashMap<String, CodeUnit> {
603    let imports = python.import_info_of(token, file);
604    let mut bindings = HashMap::default();
605    for resolved in resolve_imports_batched(python, file, &imports) {
606        for (binding, code_unit) in resolved {
607            bindings.insert(binding, code_unit);
608        }
609    }
610    bindings
611}
612
613/// Resolves every import in `imports` (`file`'s own imports), batching each import's primary
614/// module FQN lookup (see `primary_module_fqn`) into one store transaction instead of one per
615/// import. Shared by `resolve_import_bindings` and `resolve_import_target_files`, the two per-file
616/// "resolve everything" entry points -- both are called once per candidate file by the usages
617/// candidate walker, so unbatched resolution here means one store transaction per import times
618/// every file in the workspace.
619pub fn resolve_imports_batched(
620    python: &dyn PythonSource,
621    file: &ProjectFile,
622    imports: &[ImportInfo],
623) -> Vec<Vec<(String, CodeUnit)>> {
624    let primary_fqns: Vec<Option<String>> = imports
625        .iter()
626        .map(|import| primary_module_fqn(file, import))
627        .collect();
628    let to_resolve: Vec<String> = primary_fqns.iter().flatten().cloned().collect();
629    let mut batch_results = resolve_module_code_units_batch(python, &to_resolve).into_iter();
630
631    imports
632        .iter()
633        .zip(primary_fqns.iter())
634        .map(|(import, primary_fqn)| {
635            let hint = primary_fqn.as_ref().map(|_| batch_results.next().unwrap());
636            resolve_import_with_hint(python, file, import, hint.as_ref())
637        })
638        .collect()
639}
640
641/// Resolve Python imports to the workspace files whose modules they execute.
642///
643/// A file dependency graph does not need declaration binding: for
644/// `from pkg import member`, the dependency on `pkg` exists whether `member`
645/// is a local declaration, a re-export, or an optional external name. When
646/// `member` is itself the workspace submodule `pkg.member`, that file is a
647/// dependency too. Resolve both module spellings from the path-backed module
648/// index in one batch and deliberately avoid the exact-definition fallbacks
649/// used by [`resolve_imports_batched`].
650pub fn resolve_import_files_batched(
651    python: &dyn PythonSource,
652    file: &ProjectFile,
653    imports: &[ImportInfo],
654) -> HashSet<ProjectFile> {
655    let mut module_fqns = Vec::new();
656    for import in imports {
657        match python_import_details(import) {
658            Some(PythonImportDetails::Import { module, .. }) => module_fqns.push(module),
659            Some(PythonImportDetails::FromImport {
660                module,
661                name,
662                wildcard,
663                ..
664            }) => {
665                let resolved_module = if module.starts_with('.') {
666                    resolve_python_relative_module(file, &module)
667                } else {
668                    Some(module)
669                };
670                let Some(resolved_module) = resolved_module else {
671                    continue;
672                };
673                if !wildcard {
674                    module_fqns.push(format!("{resolved_module}.{name}"));
675                }
676                module_fqns.push(resolved_module);
677            }
678            None => {}
679        }
680    }
681    module_fqns.sort();
682    module_fqns.dedup();
683
684    python
685        .path_module_fqns_batch(&module_fqns)
686        .into_iter()
687        .flatten()
688        .flatten()
689        .filter(CodeUnit::is_module)
690        .map(|unit| unit.source().clone())
691        .collect()
692}
693
694/// The module FQN `resolve_import`'s fast path checks first, if any -- must stay in sync with the
695/// two `resolve_module_code_unit` call sites in `resolve_import_with_hint` below, since it's what
696/// lets `resolve_import_target_files` batch-resolve them ahead of the serial fallback logic.
697fn primary_module_fqn(file: &ProjectFile, import: &ImportInfo) -> Option<String> {
698    match python_import_details(import)? {
699        PythonImportDetails::Import { module, alias } => Some(python_namespace_binding_module(
700            import,
701            alias.as_deref(),
702            &module,
703        )),
704        PythonImportDetails::FromImport {
705            module,
706            name,
707            wildcard,
708            ..
709        } => {
710            if wildcard {
711                return None;
712            }
713            let resolved_module = if module.starts_with('.') {
714                resolve_python_relative_module(file, &module)
715            } else {
716                Some(module)
717            };
718            resolved_module.map(|resolved_module| format!("{resolved_module}.{name}"))
719        }
720    }
721}
722
723pub fn resolve_import(
724    python: &dyn PythonSource,
725    file: &ProjectFile,
726    import: &ImportInfo,
727) -> Vec<(String, CodeUnit)> {
728    resolve_import_with_hint(python, file, import, None)
729}
730
731/// `primary_hint`, when `Some`, is the already-resolved result of this import's primary module FQN
732/// (see `primary_module_fqn`) so the batched caller doesn't pay for a second lookup of the same FQN.
733fn resolve_import_with_hint(
734    python: &dyn PythonSource,
735    file: &ProjectFile,
736    import: &ImportInfo,
737    primary_hint: Option<&Option<CodeUnit>>,
738) -> Vec<(String, CodeUnit)> {
739    if let Some(details) = python_import_details(import) {
740        match details {
741            PythonImportDetails::Import { module, alias } => {
742                let binding = python_namespace_binding_name(import, alias.as_deref(), &module);
743                let bound_module =
744                    python_namespace_binding_module(import, alias.as_deref(), &module);
745                let resolved = match primary_hint {
746                    Some(hint) => hint.clone(),
747                    None => resolve_module_code_unit(python, &bound_module),
748                };
749                if let Some(module_code_unit) = resolved {
750                    return vec![(binding, module_code_unit)];
751                }
752            }
753            PythonImportDetails::FromImport {
754                module,
755                name,
756                alias,
757                wildcard,
758            } => {
759                let resolved_module = if module.starts_with('.') {
760                    resolve_python_relative_module(file, &module)
761                } else {
762                    Some(module)
763                };
764                let Some(resolved_module) = resolved_module else {
765                    return Vec::new();
766                };
767                if wildcard {
768                    return public_declarations_in_module(python, &resolved_module)
769                        .into_iter()
770                        .map(|code_unit| (code_unit.identifier().to_string(), code_unit))
771                        .collect();
772                }
773
774                let binding = alias.clone().unwrap_or_else(|| name.clone());
775                let module_candidate = format!("{resolved_module}.{name}");
776                let resolved = match primary_hint {
777                    Some(hint) => hint.clone(),
778                    None => resolve_module_code_unit(python, &module_candidate),
779                };
780                if let Some(code_unit) = resolved {
781                    return vec![(binding, code_unit)];
782                }
783                let exported = resolve_exported_name_from_module(python, &resolved_module, &name);
784                if !exported.is_empty() {
785                    return exported
786                        .into_iter()
787                        .map(|code_unit| (binding.clone(), code_unit))
788                        .collect();
789                }
790                let definitions: Vec<_> = python.definitions(&module_candidate).collect();
791                if !definitions.is_empty() {
792                    return definitions
793                        .into_iter()
794                        .map(|code_unit| (binding.clone(), code_unit))
795                        .collect();
796                }
797                let package_candidate: Vec<_> = python
798                    .definitions(&format!("{resolved_module}.{name}"))
799                    .collect();
800                if !package_candidate.is_empty() {
801                    return package_candidate
802                        .into_iter()
803                        .map(|code_unit| (binding.clone(), code_unit))
804                        .collect();
805                }
806            }
807        }
808    }
809    Vec::new()
810}
811
812pub fn resolve_exported_fqn(python: &dyn PythonSource, fqn: &str) -> Vec<CodeUnit> {
813    let Some((module, name)) = fqn.rsplit_once('.') else {
814        return Vec::new();
815    };
816    resolve_exported_name_from_module(python, module, name)
817}
818
819/// Resolve an unambiguous chain of explicit named reexports without
820/// constructing export indexes for each intermediate module. Star exports,
821/// shadowing, and every other ambiguous shape return `None` so callers can
822/// use the complete, source-order-aware export resolver below.
823fn resolve_direct_named_exported_fqn(
824    python: &dyn PythonSource,
825    fqn: &str,
826) -> Option<Vec<CodeUnit>> {
827    let (module, name) = fqn.rsplit_once('.')?;
828    let mut results = Vec::new();
829    let mut queue = VecDeque::from([(module.to_string(), name.to_string())]);
830    let mut visited = HashSet::default();
831
832    while let Some((module, export_name)) = queue.pop_front() {
833        if !visited.insert((module.clone(), export_name.clone())) {
834            continue;
835        }
836        let module_unit = resolve_module_code_unit(python, &module)?;
837        let file = module_unit.source();
838        let local = local_export_declarations(python, file, &export_name);
839        let binder = python.import_binder_of(file);
840        let binding = binder.bindings.get(&export_name);
841        if !local.is_empty() && binding.is_some() {
842            return None;
843        }
844        if !local.is_empty() {
845            results.extend(local);
846            continue;
847        }
848        let binding = binding?;
849        if binding.kind != ImportKind::Named {
850            return None;
851        }
852        let imported_name = binding.imported_name.as_ref()?;
853        queue.push_back((binding.module_specifier.clone(), imported_name.clone()));
854    }
855
856    results.sort_by(|left, right| {
857        left.source()
858            .cmp(right.source())
859            .then_with(|| left.fq_name().cmp(&right.fq_name()))
860    });
861    results.dedup();
862    (!results.is_empty()).then_some(results)
863}
864
865/// Resolve a Python FQN with the cheapest semantically complete tier that
866/// can answer it. The direct reexport walk handles only proven,
867/// collision-free chains; ambiguous shapes use the ordered export index,
868/// and the exact lookup remains the final fallback for non-export symbols.
869pub fn resolve_fqn_candidates(
870    python: &dyn PythonSource,
871    fqn: &str,
872    exact: impl FnOnce(&str) -> Vec<CodeUnit>,
873) -> Vec<CodeUnit> {
874    if let Some(candidates) = resolve_direct_named_exported_fqn(python, fqn) {
875        return candidates;
876    }
877    let candidates = resolve_exported_fqn(python, fqn);
878    if !candidates.is_empty() {
879        return candidates;
880    }
881    exact(fqn)
882}
883
884fn resolve_exported_name_from_module(
885    python: &dyn PythonSource,
886    module: &str,
887    name: &str,
888) -> Vec<CodeUnit> {
889    let Some(module_unit) = resolve_module_code_unit(python, module) else {
890        return Vec::new();
891    };
892    resolve_exported_name(python, module_unit.source(), name)
893}
894
895fn resolve_exported_name(
896    python: &dyn PythonSource,
897    module_file: &ProjectFile,
898    name: &str,
899) -> Vec<CodeUnit> {
900    let mut results = Vec::new();
901    let mut queue = VecDeque::from([(module_file.clone(), name.to_string())]);
902    let mut visited = HashSet::default();
903
904    while let Some((file, export_name)) = queue.pop_front() {
905        if !visited.insert((file.clone(), export_name.clone())) {
906            continue;
907        }
908
909        let index = python.export_index_of(&file);
910        if let Some(entry) = index.exports_by_name.get(&export_name) {
911            match entry {
912                ExportEntry::Local { local_name } => {
913                    results.extend(local_export_declarations(python, &file, local_name));
914                }
915                ExportEntry::ReexportedNamed {
916                    module_specifier,
917                    imported_name,
918                } => {
919                    for target_file in
920                        resolve_module_files_for_export(python, &file, module_specifier)
921                    {
922                        queue.push_back((target_file, imported_name.clone()));
923                    }
924                }
925                ExportEntry::ReexportedModule { module_specifier } => {
926                    // Terminal: the export *is* the module, so the walk stops
927                    // here instead of looking the name up inside it.
928                    results.extend(resolve_module_code_unit(python, module_specifier));
929                }
930                ExportEntry::Default { local_name } => {
931                    if let Some(local_name) = local_name {
932                        results.extend(local_export_declarations(python, &file, local_name));
933                    }
934                }
935            }
936            continue;
937        }
938
939        if !export_name.starts_with('_') {
940            for star in &index.reexport_stars {
941                for target_file in
942                    resolve_module_files_for_export(python, &file, &star.module_specifier)
943                {
944                    queue.push_back((target_file, export_name.clone()));
945                }
946            }
947        }
948    }
949
950    results.sort_by(|left, right| {
951        left.source()
952            .cmp(right.source())
953            .then_with(|| left.fq_name().cmp(&right.fq_name()))
954    });
955    results.dedup();
956    results
957}
958
959fn local_export_declarations(
960    index: &dyn CodeUnitIndex,
961    file: &ProjectFile,
962    local_name: &str,
963) -> Vec<CodeUnit> {
964    index
965        .top_level_declarations(file)
966        .into_iter()
967        .filter(|unit| {
968            unit.identifier() == local_name
969                && index
970                    .parent_of(unit)
971                    .is_some_and(|parent| parent.is_module() && parent.source() == file)
972        })
973        .collect()
974}
975
976fn resolve_module_files_for_export(
977    python: &dyn PythonSource,
978    importing_file: &ProjectFile,
979    module_specifier: &str,
980) -> Vec<ProjectFile> {
981    let resolved_module = if module_specifier.starts_with('.') {
982        resolve_python_relative_module(importing_file, module_specifier)
983    } else {
984        Some(module_specifier.to_string())
985    };
986    let Some(resolved_module) = resolved_module else {
987        return Vec::new();
988    };
989    // Tree-sitter tells us the import syntax, but module-to-file resolution
990    // is analyzer state. Use the prebuilt module code-unit map here instead
991    // of the usage index so interactive definition lookup stays lightweight.
992    resolve_module_code_unit(python, &resolved_module)
993        .map(|unit| vec![unit.source().clone()])
994        .unwrap_or_default()
995}
996
997pub fn extract_package_from_python_wildcard(import: &ImportInfo) -> Option<String> {
998    let details = python_import_details(import)?;
999    match details {
1000        PythonImportDetails::FromImport {
1001            module, wildcard, ..
1002        } if wildcard => Some(module),
1003        _ => None,
1004    }
1005}
1006
1007#[derive(Debug, Clone)]
1008pub enum PythonImportDetails {
1009    Import {
1010        module: String,
1011        alias: Option<String>,
1012    },
1013    FromImport {
1014        module: String,
1015        name: String,
1016        alias: Option<String>,
1017        wildcard: bool,
1018    },
1019}
1020
1021pub fn python_import_infos_from_node(node: Node<'_>, source: &str) -> Vec<ImportInfo> {
1022    match node.kind() {
1023        "import_statement" => python_namespace_import_infos(node, source),
1024        "import_from_statement" => python_from_import_infos(node, source),
1025        _ => Vec::new(),
1026    }
1027}
1028
1029pub fn python_import_details(import: &ImportInfo) -> Option<PythonImportDetails> {
1030    let path = import.path.as_ref()?;
1031    match path.kind? {
1032        StructuredImportPathKind::Namespace => Some(PythonImportDetails::Import {
1033            module: join_python_import_segments(&path.segments),
1034            alias: import.alias.clone(),
1035        }),
1036        // Python has no static imports; the variant belongs to Java.
1037        StructuredImportPathKind::StaticMember => None,
1038        StructuredImportPathKind::ImportFrom => {
1039            let (name, module_segments) = if import.is_wildcard {
1040                ("*".to_string(), path.segments.as_slice())
1041            } else {
1042                let (name, module_segments) = path.segments.split_last()?;
1043                (name.clone(), module_segments)
1044            };
1045            Some(PythonImportDetails::FromImport {
1046                module: join_python_import_segments(module_segments),
1047                name,
1048                alias: import.alias.clone(),
1049                wildcard: import.is_wildcard,
1050            })
1051        }
1052    }
1053}
1054
1055fn python_namespace_import_infos(node: Node<'_>, source: &str) -> Vec<ImportInfo> {
1056    let mut infos = Vec::new();
1057    let mut cursor = node.walk();
1058    for imported in node.children_by_field_name("name", &mut cursor) {
1059        let (module_node, alias_node) = if imported.kind() == "aliased_import" {
1060            let Some(name) = imported.child_by_field_name("name") else {
1061                continue;
1062            };
1063            (name, imported.child_by_field_name("alias"))
1064        } else {
1065            (imported, None)
1066        };
1067        let alias = alias_node
1068            .map(|alias| py_node_text(alias, source).trim().to_string())
1069            .filter(|alias| !alias.is_empty());
1070        let segments = python_path_segments(module_node, source);
1071        if segments.is_empty() {
1072            continue;
1073        }
1074        // `import a.b` binds `a`: the first segment's own token. A renamed
1075        // import binds its alias token instead.
1076        let binder_span = alias
1077            .is_some()
1078            .then_some(alias_node)
1079            .flatten()
1080            .or_else(|| python_first_segment_node(module_node))
1081            .map(brokk_bifrost_core::analyzer::common::node_span);
1082        let module = join_python_import_segments(&segments);
1083        let identifier = alias.clone().or_else(|| segments.first().cloned());
1084        infos.push(ImportInfo {
1085            raw_snippet: if let Some(alias) = &alias {
1086                format!("import {module} as {alias}")
1087            } else {
1088                format!("import {module}")
1089            },
1090            is_wildcard: false,
1091            is_global: false,
1092            identifier,
1093            alias,
1094            path: Some(StructuredImportPath {
1095                segments,
1096                kind: Some(StructuredImportPathKind::Namespace),
1097                lexical_prefixes: Vec::new(),
1098                lexical_scopes: Vec::new(),
1099                declaration_start_byte: node.start_byte(),
1100            }),
1101            binder_span,
1102        });
1103    }
1104    infos
1105}
1106
1107fn python_from_import_infos(node: Node<'_>, source: &str) -> Vec<ImportInfo> {
1108    let Some(module_node) = node.child_by_field_name("module_name") else {
1109        return Vec::new();
1110    };
1111    let module_segments = python_module_segments(module_node, source);
1112    if module_segments.is_empty() {
1113        return Vec::new();
1114    }
1115
1116    let mut infos = Vec::new();
1117    let has_wildcard_import = {
1118        let mut cursor = node.walk();
1119        node.named_children(&mut cursor)
1120            .any(|child| child.kind() == "wildcard_import")
1121    };
1122    let mut cursor = node.walk();
1123    let imported_names: Vec<_> = node.children_by_field_name("name", &mut cursor).collect();
1124    if has_wildcard_import {
1125        let module = join_python_import_segments(&module_segments);
1126        infos.push(ImportInfo {
1127            raw_snippet: format!("from {module} import *"),
1128            is_wildcard: true,
1129            is_global: false,
1130            identifier: None,
1131            alias: None,
1132            path: Some(StructuredImportPath {
1133                segments: module_segments,
1134                kind: Some(StructuredImportPathKind::ImportFrom),
1135                lexical_prefixes: Vec::new(),
1136                lexical_scopes: Vec::new(),
1137                declaration_start_byte: node.start_byte(),
1138            }),
1139            binder_span: None,
1140        });
1141        return infos;
1142    }
1143    if imported_names.is_empty() {
1144        return infos;
1145    }
1146
1147    for imported in imported_names {
1148        let (name_node, alias_node) = if imported.kind() == "aliased_import" {
1149            let Some(name) = imported.child_by_field_name("name") else {
1150                continue;
1151            };
1152            (name, imported.child_by_field_name("alias"))
1153        } else {
1154            (imported, None)
1155        };
1156        let alias = alias_node
1157            .map(|alias| py_node_text(alias, source).trim().to_string())
1158            .filter(|alias| !alias.is_empty());
1159        let name_segments = python_path_segments(name_node, source);
1160        if name_segments.is_empty() {
1161            continue;
1162        }
1163        // `from m import x` binds `x`'s own token; a rename binds the alias
1164        // token. A multi-segment imported name binds no single token.
1165        let binder_span = alias
1166            .is_some()
1167            .then_some(alias_node)
1168            .flatten()
1169            .or_else(|| {
1170                (name_segments.len() == 1)
1171                    .then(|| python_first_segment_node(name_node))
1172                    .flatten()
1173            })
1174            .map(brokk_bifrost_core::analyzer::common::node_span);
1175        let imported_name = join_python_import_segments(&name_segments);
1176        let mut segments = module_segments.clone();
1177        segments.extend(name_segments);
1178        let module = join_python_import_segments(&module_segments);
1179        infos.push(ImportInfo {
1180            raw_snippet: if let Some(alias) = &alias {
1181                format!("from {module} import {imported_name} as {alias}")
1182            } else {
1183                format!("from {module} import {imported_name}")
1184            },
1185            is_wildcard: false,
1186            is_global: false,
1187            identifier: Some(alias.clone().unwrap_or_else(|| imported_name.clone())),
1188            alias,
1189            path: Some(StructuredImportPath {
1190                segments,
1191                kind: Some(StructuredImportPathKind::ImportFrom),
1192                lexical_prefixes: Vec::new(),
1193                lexical_scopes: Vec::new(),
1194                declaration_start_byte: node.start_byte(),
1195            }),
1196            binder_span,
1197        });
1198    }
1199    infos
1200}
1201
1202fn python_module_segments(module: Node<'_>, source: &str) -> Vec<String> {
1203    if module.kind() == "relative_import" {
1204        let mut cursor = module.walk();
1205        let mut prefix = String::new();
1206        let mut path_node = None;
1207        for child in module.named_children(&mut cursor) {
1208            match child.kind() {
1209                "import_prefix" if prefix.is_empty() => {
1210                    prefix = py_node_text(child, source).trim().to_string();
1211                }
1212                "dotted_name" if path_node.is_none() => {
1213                    path_node = Some(child);
1214                }
1215                _ => {}
1216            }
1217        }
1218        let mut segments = path_node
1219            .map(|path| python_path_segments(path, source))
1220            .unwrap_or_default();
1221        if !prefix.is_empty() {
1222            if let Some(first) = segments.first_mut() {
1223                first.insert_str(0, &prefix);
1224            } else {
1225                segments.push(prefix);
1226            }
1227        }
1228        return segments;
1229    }
1230    python_path_segments(module, source)
1231}
1232
1233/// The token that spells a path's first segment: the identifier itself, or a
1234/// dotted name's first identifier. `None` when the shape has no leading
1235/// identifier token of its own (e.g. a relative-import prefix).
1236fn python_first_segment_node(node: Node<'_>) -> Option<Node<'_>> {
1237    match node.kind() {
1238        "identifier" => Some(node),
1239        "dotted_name" => {
1240            let mut cursor = node.walk();
1241            node.named_children(&mut cursor)
1242                .find(|child| child.kind() == "identifier")
1243        }
1244        _ => None,
1245    }
1246}
1247
1248fn python_path_segments(node: Node<'_>, source: &str) -> Vec<String> {
1249    match node.kind() {
1250        "identifier" => vec![py_node_text(node, source).trim().to_string()],
1251        "dotted_name" => {
1252            let mut segments = Vec::new();
1253            let mut cursor = node.walk();
1254            for child in node.named_children(&mut cursor) {
1255                segments.extend(python_path_segments(child, source));
1256            }
1257            segments
1258        }
1259        _ => {
1260            let mut segments = Vec::new();
1261            let mut cursor = node.walk();
1262            for child in node.named_children(&mut cursor) {
1263                segments.extend(python_path_segments(child, source));
1264            }
1265            segments
1266        }
1267    }
1268}
1269
1270fn join_python_import_segments(segments: &[String]) -> String {
1271    let Some((first, rest)) = segments.split_first() else {
1272        return String::new();
1273    };
1274    if first.starts_with('.') && !rest.is_empty() {
1275        format!("{first}.{}", rest.join("."))
1276    } else {
1277        segments.join(".")
1278    }
1279}
1280
1281pub fn python_namespace_binding_name(
1282    import: &ImportInfo,
1283    alias: Option<&str>,
1284    module: &str,
1285) -> String {
1286    import
1287        .identifier
1288        .clone()
1289        .or_else(|| alias.map(str::to_string))
1290        .unwrap_or_else(|| module.to_string())
1291}
1292
1293pub fn python_namespace_binding_module(
1294    import: &ImportInfo,
1295    alias: Option<&str>,
1296    module: &str,
1297) -> String {
1298    if alias.is_some() {
1299        return module.to_string();
1300    }
1301    import
1302        .path
1303        .as_ref()
1304        .and_then(|path| path.segments.first().cloned())
1305        .unwrap_or_else(|| module.to_string())
1306}
1307
1308pub fn resolve_python_relative_module(
1309    source_file: &ProjectFile,
1310    module_expr: &str,
1311) -> Option<String> {
1312    resolve_python_relative_module_from_package(&python_current_package(source_file), module_expr)
1313}
1314
1315/// Resolve a structured Python module expression against an already-known
1316/// package identity. Dependency-pack producers know module identities without
1317/// owning a workspace [`ProjectFile`], so they use this entry point.
1318pub fn resolve_python_relative_module_from_package(
1319    current_package: &str,
1320    module_expr: &str,
1321) -> Option<String> {
1322    let level = module_expr.chars().take_while(|ch| *ch == '.').count();
1323    let suffix = module_expr[level..].trim_matches('.');
1324    let mut parts: Vec<_> = current_package
1325        .split('.')
1326        .filter(|part| !part.is_empty())
1327        .map(str::to_string)
1328        .collect();
1329    if level == 0 {
1330        return Some(module_expr.to_string());
1331    }
1332    if level > 0 {
1333        if level - 1 > parts.len() {
1334            return None;
1335        }
1336        parts.truncate(parts.len() - (level - 1));
1337    }
1338    if !suffix.is_empty() {
1339        parts.extend(suffix.split('.').map(str::to_string));
1340    }
1341    Some(parts.join("."))
1342}
1343
1344fn python_current_package(source_file: &ProjectFile) -> String {
1345    let module = python_module_name(source_file);
1346    if source_file
1347        .rel_path()
1348        .file_name()
1349        .and_then(|name| name.to_str())
1350        == Some("__init__.py")
1351    {
1352        module
1353    } else {
1354        module
1355            .rsplit_once('.')
1356            .map(|(package, _)| package.to_string())
1357            .unwrap_or_default()
1358    }
1359}