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 crate::runtime_values::enclosing_execution_context;
14use crate::syntax::python_static_attribute_path;
15use brokk_bifrost_core::analyzer::common::node_source_text;
16use brokk_bifrost_core::analyzer::model::{
17    ImportInfo, StructuredImportPath, StructuredImportPathKind,
18};
19use brokk_bifrost_core::analyzer::query_token::QueryToken;
20use brokk_bifrost_core::analyzer::usages::model::{ExportEntry, ImportBinding, ImportKind};
21use brokk_bifrost_core::analyzer::{CodeUnit, CodeUnitIndex, ProjectFile};
22use brokk_bifrost_core::hash::{HashMap, HashSet};
23use std::collections::VecDeque;
24use tree_sitter::Node;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct PythonModuleReplacement {
28    pub target_module: String,
29}
30
31/// Return the absolute module named by the last direct assignment to `local`
32/// that dominates `reference` when that assignment has the exact structured
33/// shape `local = importlib_binding.import_module("module.name")`.
34///
35/// Assignments hidden in a branch do not dominate the reference. Any later
36/// direct binding of `local` invalidates an earlier result. The caller proves
37/// that the receiver of `import_module` is an in-scope namespace binding for
38/// Python's `importlib` module; this helper only interprets the syntax and
39/// statement order.
40pub(crate) fn imported_module_assignment_at(
41    reference: Node<'_>,
42    local: &str,
43    source: &str,
44    mut is_importlib_binding: impl FnMut(&str) -> bool,
45) -> Option<String> {
46    let suite = enclosing_execution_suite(reference)?;
47    let mut resolved = None;
48    let mut cursor = suite.walk();
49    for statement in suite.named_children(&mut cursor) {
50        if statement.start_byte() >= reference.start_byte()
51            || (statement.start_byte() <= reference.start_byte()
52                && reference.end_byte() <= statement.end_byte())
53        {
54            break;
55        }
56
57        let expression = if statement.kind() == "expression_statement" {
58            statement.named_child(0).unwrap_or(statement)
59        } else {
60            statement
61        };
62        if expression.kind() == "assignment"
63            && expression.child_by_field_name("left").is_some_and(|left| {
64                left.kind() == "identifier" && node_source_text(left, source) == local
65            })
66        {
67            resolved = expression
68                .child_by_field_name("right")
69                .and_then(|right| importlib_call_module(right, source, &mut is_importlib_binding));
70            continue;
71        }
72
73        let binds_local = python_direct_scope_bindings_bounded(statement, source, || true)
74            .expect("unbounded binding collection cannot be cancelled")
75            .into_iter()
76            .any(|binding| node_source_text(binding.declaration, source) == local);
77        if binds_local {
78            resolved = None;
79        }
80    }
81    resolved
82}
83
84/// Resolve a local name assigned a module object before `reference`.
85///
86/// The caller resolves imported module expressions from its structured import
87/// binder. This helper follows only straight-line assignments in the current
88/// function or module suite; branches and other direct rebinding statements
89/// invalidate an earlier alias. Attribute paths are read from tree-sitter
90/// nodes, never reconstructed from source text.
91pub fn python_local_module_alias_path_before<E, C>(
92    reference: Node<'_>,
93    local_name: &str,
94    source: &str,
95    context: &mut C,
96    mut imported_module_path: impl FnMut(Node<'_>, &mut C) -> Result<Option<Box<[Box<str>]>>, E>,
97    mut charge_step: impl FnMut(&mut C) -> Result<(), E>,
98) -> Result<Option<Box<[Box<str>]>>, E> {
99    let mut current = reference;
100    let suite = loop {
101        let Some(parent) = current.parent() else {
102            return Ok(None);
103        };
104        match parent.kind() {
105            "function_definition" | "lambda" => break parent.child_by_field_name("body"),
106            "module" => break Some(parent),
107            "class_definition" => return Ok(None),
108            _ => current = parent,
109        }
110    };
111    let Some(suite) = suite else {
112        return Ok(None);
113    };
114
115    let mut aliases: HashMap<Box<str>, Box<[Box<str>]>> = HashMap::default();
116    let mut cursor = suite.walk();
117    for statement in suite.named_children(&mut cursor) {
118        if statement.start_byte() >= reference.start_byte()
119            || (statement.start_byte() <= reference.start_byte()
120                && reference.end_byte() <= statement.end_byte())
121        {
122            break;
123        }
124        charge_step(context)?;
125        let expression = if statement.kind() == "expression_statement" {
126            statement.named_child(0).unwrap_or(statement)
127        } else {
128            statement
129        };
130        if expression.kind() == "assignment" {
131            let (targets, value) = python_assignment_chain(expression);
132            if !targets.is_empty() && targets.iter().all(|target| target.kind() == "identifier") {
133                let module_path = value
134                    .map(|value| {
135                        python_module_alias_expression_path(
136                            value,
137                            source,
138                            &aliases,
139                            context,
140                            &mut imported_module_path,
141                        )
142                    })
143                    .transpose()?
144                    .flatten();
145                for target in targets {
146                    let name = node_source_text(target, source);
147                    if let Some(module_path) = module_path.as_ref() {
148                        aliases.insert(name.into(), module_path.clone());
149                    } else {
150                        aliases.remove(name);
151                    }
152                }
153                continue;
154            }
155        }
156
157        if !matches!(
158            expression.kind(),
159            "assignment"
160                | "augmented_assignment"
161                | "delete_statement"
162                | "for_statement"
163                | "async_for_statement"
164                | "while_statement"
165                | "with_statement"
166                | "try_statement"
167                | "match_statement"
168                | "import_statement"
169                | "import_from_statement"
170                | "function_definition"
171                | "class_definition"
172                | "global_statement"
173                | "nonlocal_statement"
174                | "named_expression"
175                | "if_statement"
176        ) {
177            continue;
178        }
179        let mut binding_error = None;
180        let bindings =
181            python_direct_scope_bindings_bounded(statement, source, || {
182                match charge_step(context) {
183                    Ok(()) => true,
184                    Err(error) => {
185                        binding_error = Some(error);
186                        false
187                    }
188                }
189            });
190        if let Some(error) = binding_error {
191            return Err(error);
192        }
193        let bindings = bindings.expect("bounded Python alias scan stopped without a cause");
194        for binding in bindings {
195            aliases.remove(node_source_text(binding.declaration, source));
196        }
197    }
198    Ok(aliases.get(local_name).cloned())
199}
200
201fn python_module_alias_expression_path<E, C>(
202    expression: Node<'_>,
203    source: &str,
204    aliases: &HashMap<Box<str>, Box<[Box<str>]>>,
205    context: &mut C,
206    imported_module_path: &mut impl FnMut(Node<'_>, &mut C) -> Result<Option<Box<[Box<str>]>>, E>,
207) -> Result<Option<Box<[Box<str>]>>, E> {
208    if let Some(path) = python_static_attribute_path(expression)
209        && let Some(name) = path
210            .first()
211            .map(|segment| node_source_text(*segment, source))
212        && let Some(module_path) = aliases.get(name)
213    {
214        let mut canonical = module_path.to_vec();
215        canonical.extend(path.iter().skip(1).map(|segment| {
216            node_source_text(*segment, source)
217                .to_owned()
218                .into_boxed_str()
219        }));
220        return Ok(Some(canonical.into_boxed_slice()));
221    }
222    imported_module_path(expression, context)
223}
224
225/// Flatten a right-recursive assignment into its targets and terminal value.
226/// Targets are returned in runtime order, followed by the single evaluated
227/// right-hand expression.
228pub fn python_assignment_chain<'tree>(
229    node: Node<'tree>,
230) -> (Vec<Node<'tree>>, Option<Node<'tree>>) {
231    let mut targets = Vec::new();
232    let mut assignment = node;
233    loop {
234        let Some(target) = assignment.child_by_field_name("left") else {
235            return (targets, None);
236        };
237        targets.push(target);
238        let Some(value) = assignment.child_by_field_name("right") else {
239            return (targets, None);
240        };
241        if value.kind() != "assignment" {
242            return (targets, Some(value));
243        }
244        assignment = value;
245    }
246}
247
248fn enclosing_execution_suite(node: Node<'_>) -> Option<Node<'_>> {
249    if let Some(function) = enclosing_execution_context(node) {
250        return function.child_by_field_name("body");
251    }
252
253    let mut current = node;
254    while let Some(parent) = current.parent() {
255        if parent.kind() == "class_definition" {
256            return parent.child_by_field_name("body");
257        }
258        if parent.kind() == "module" {
259            return Some(parent);
260        }
261        current = parent;
262    }
263    None
264}
265
266fn importlib_call_module(
267    call: Node<'_>,
268    source: &str,
269    is_importlib_binding: &mut impl FnMut(&str) -> bool,
270) -> Option<String> {
271    if call.kind() != "call" {
272        return None;
273    }
274    let function = call.child_by_field_name("function")?;
275    if function.kind() != "attribute" {
276        return None;
277    }
278    let (object, attribute) = (
279        function.child_by_field_name("object")?,
280        function.child_by_field_name("attribute")?,
281    );
282    if object.kind() != "identifier"
283        || attribute.kind() != "identifier"
284        || node_source_text(attribute, source) != "import_module"
285        || !is_importlib_binding(node_source_text(object, source))
286    {
287        return None;
288    }
289
290    let arguments = call.child_by_field_name("arguments")?;
291    if arguments.named_child_count() != 1 {
292        return None;
293    }
294    let literal = arguments.named_child(0)?;
295    if literal.kind() != "string"
296        || literal
297            .parent()
298            .is_some_and(|parent| parent.kind() == "concatenated_string")
299    {
300        return None;
301    }
302    let mut content = None;
303    for index in 0..literal.named_child_count() {
304        let child = literal.named_child(index)?;
305        match child.kind() {
306            "string_start" | "string_end" => {}
307            "string_content" if content.is_none() && child.named_child_count() == 0 => {
308                content = Some(child);
309            }
310            _ => return None,
311        }
312    }
313    let content = content?;
314    let module = node_source_text(content, source);
315    (!module.is_empty() && !module.starts_with('.')).then(|| module.to_string())
316}
317
318/// Collect absolute module names from structurally exact
319/// `local = importlib.import_module("...")` assignments. This is a conservative
320/// candidate-file index: dominance and local shadowing are checked later at the
321/// reference site by [`imported_module_assignment_at`].
322pub(crate) fn literal_importlib_modules(
323    source: &str,
324    bindings: &HashMap<String, ImportBinding>,
325) -> HashSet<String> {
326    let Some(tree) = parse_python_tree(source) else {
327        return HashSet::default();
328    };
329    let mut modules = HashSet::default();
330    let mut stack = vec![tree.root_node()];
331    while let Some(node) = stack.pop() {
332        if node.kind() == "assignment"
333            && node
334                .child_by_field_name("left")
335                .is_some_and(|left| left.kind() == "identifier")
336            && let Some(module) = node.child_by_field_name("right").and_then(|right| {
337                importlib_call_module(right, source, &mut |local| {
338                    bindings.get(local).is_some_and(|binding| {
339                        binding.kind == ImportKind::Namespace
340                            && binding
341                                .namespace_imported_module
342                                .as_deref()
343                                .unwrap_or(&binding.module_specifier)
344                                == "importlib"
345                    })
346                })
347            })
348        {
349            modules.insert(module);
350        }
351        let mut cursor = node.walk();
352        stack.extend(node.named_children(&mut cursor));
353    }
354    modules
355}
356
357/// Recognize the compatibility-shim idiom that replaces the current module
358/// object with an imported workspace module:
359///
360/// `sys.modules[__name__] = imported_module`
361///
362/// Every component is resolved from tree-sitter fields and the import binder;
363/// similarly spelled attributes or locals do not create an alias edge.
364fn module_replacement_from_assignment(
365    assignment: Node<'_>,
366    source: &str,
367    bindings: &HashMap<String, ImportBinding>,
368) -> Option<PythonModuleReplacement> {
369    let (left, right) = (
370        assignment.child_by_field_name("left")?,
371        assignment.child_by_field_name("right")?,
372    );
373    if left.kind() != "subscript" || right.kind() != "identifier" {
374        return None;
375    }
376    let (value, subscript) = (
377        left.child_by_field_name("value")?,
378        left.child_by_field_name("subscript")?,
379    );
380    if value.kind() != "attribute"
381        || subscript.kind() != "identifier"
382        || node_source_text(subscript, source) != "__name__"
383    {
384        return None;
385    }
386    let (sys_local, modules) = (
387        value.child_by_field_name("object")?,
388        value.child_by_field_name("attribute")?,
389    );
390    if sys_local.kind() != "identifier"
391        || modules.kind() != "identifier"
392        || node_source_text(modules, source) != "modules"
393    {
394        return None;
395    }
396    let sys_binding = bindings.get(node_source_text(sys_local, source))?;
397    let sys_module = sys_binding
398        .namespace_imported_module
399        .as_deref()
400        .unwrap_or(&sys_binding.module_specifier);
401    if sys_binding.kind != ImportKind::Namespace || sys_module != "sys" {
402        return None;
403    }
404
405    let target_binding = bindings.get(node_source_text(right, source))?;
406    if target_binding.kind != ImportKind::Namespace {
407        return None;
408    }
409    Some(PythonModuleReplacement {
410        target_module: target_binding
411            .namespace_imported_module
412            .clone()
413            .unwrap_or_else(|| target_binding.module_specifier.clone()),
414    })
415}
416
417fn remove_direct_scope_bindings(
418    statement: Node<'_>,
419    source: &str,
420    bindings: &mut HashMap<String, ImportBinding>,
421) {
422    let mut stack = vec![statement];
423    while let Some(node) = stack.pop() {
424        for binding in python_direct_scope_bindings_bounded(node, source, || true)
425            .expect("unbounded binding collection cannot be cancelled")
426        {
427            bindings.remove(node_source_text(binding.declaration, source));
428        }
429        if matches!(
430            node.kind(),
431            "function_definition" | "class_definition" | "lambda"
432        ) {
433            continue;
434        }
435        let mut cursor = node.walk();
436        stack.extend(node.named_children(&mut cursor));
437    }
438}
439
440fn python_import_statement_nodes<'tree>(root: Node<'tree>) -> Vec<Node<'tree>> {
441    let mut pending = vec![root];
442    let mut nodes = Vec::new();
443    while let Some(node) = pending.pop() {
444        if matches!(node.kind(), "import_statement" | "import_from_statement") {
445            nodes.push(node);
446            continue;
447        }
448        let mut cursor = node.walk();
449        pending.extend(node.named_children(&mut cursor));
450    }
451    nodes
452}
453
454#[derive(Debug, Clone, PartialEq, Eq)]
455pub struct PythonImportBinding {
456    pub start_byte: usize,
457    pub scope_start_byte: usize,
458    pub scope_end_byte: usize,
459    function_scoped: bool,
460    pub local_name: String,
461    pub qualified_name: String,
462    pub consumed_attributes: usize,
463}
464
465impl PythonImportBinding {
466    pub fn is_function_scoped(&self) -> bool {
467        self.function_scoped
468    }
469}
470
471/// Select the latest visible function-scoped import binding for `name`.
472pub fn python_function_scoped_import_binding<'a>(
473    bindings: &'a [PythonImportBinding],
474    name: &str,
475    reference: Node<'_>,
476) -> Option<&'a PythonImportBinding> {
477    bindings.iter().rev().find(|binding| {
478        binding.is_function_scoped()
479            && binding.start_byte <= reference.start_byte()
480            && binding.scope_start_byte <= reference.start_byte()
481            && reference.end_byte() <= binding.scope_end_byte
482            && binding.local_name == name
483    })
484}
485
486/// Return structured import bindings from a tree the caller already owns.
487/// Query paths use this form so one request never reparses a document merely
488/// to recover function-local import scope.
489pub fn python_import_bindings_from_tree(root: Node<'_>, source: &str) -> Vec<PythonImportBinding> {
490    let mut nodes = python_import_statement_nodes(root);
491    nodes.sort_by_key(Node::start_byte);
492    nodes
493        .into_iter()
494        .flat_map(|node| {
495            let (scope_start_byte, scope_end_byte, function_scoped) =
496                python_import_binding_scope(node, source.len());
497            python_import_infos_from_node(node, source)
498                .into_iter()
499                .filter_map(move |import| {
500                    let path = import.path.as_ref()?;
501                    let details = python_import_details(&import)?;
502                    match details {
503                        PythonImportDetails::Import { module, alias } => {
504                            let consumed_attributes = if alias.is_some() {
505                                0
506                            } else {
507                                path.segments.len().saturating_sub(1)
508                            };
509                            Some(PythonImportBinding {
510                                start_byte: node.start_byte(),
511                                scope_start_byte,
512                                scope_end_byte,
513                                function_scoped,
514                                local_name: import.identifier.clone()?,
515                                qualified_name: module,
516                                consumed_attributes,
517                            })
518                        }
519                        PythonImportDetails::FromImport {
520                            module,
521                            name,
522                            alias: _,
523                            wildcard: false,
524                        } => Some(PythonImportBinding {
525                            start_byte: node.start_byte(),
526                            scope_start_byte,
527                            scope_end_byte,
528                            function_scoped,
529                            local_name: import.identifier.clone()?,
530                            qualified_name: format!("{module}.{name}"),
531                            consumed_attributes: 0,
532                        }),
533                        PythonImportDetails::FromImport { wildcard: true, .. } => None,
534                    }
535                })
536        })
537        .collect()
538}
539
540/// Build import bindings from the canonical generic import projection.
541///
542/// The producer has already interpreted each path and retained lexical scope
543/// byte ranges in the import facts. This adapter only maps those ranges back
544/// to the caller's prepared tree to recover the nearest function or lambda
545/// scope; it never reparses source or reconstructs import syntax.
546pub fn python_import_bindings_from_imports(
547    imports: &[ImportInfo],
548    root: Node<'_>,
549    source_len: usize,
550) -> Vec<PythonImportBinding> {
551    python_import_bindings_from_imports_bounded(imports, root, source_len, || true)
552        .expect("unbounded import projection completes")
553}
554
555/// Project canonical import facts under the caller's own sticky traversal
556/// budget. A stopped projection is distinct from a complete empty inventory.
557pub fn python_import_bindings_from_imports_bounded(
558    imports: &[ImportInfo],
559    root: Node<'_>,
560    source_len: usize,
561    mut scope_step: impl FnMut() -> bool,
562) -> Option<Vec<PythonImportBinding>> {
563    let mut bindings = Vec::new();
564    for import in imports {
565        if !scope_step() {
566            return None;
567        }
568        let Some(path) = import.path.as_ref() else {
569            continue;
570        };
571        for _segment in &path.segments {
572            if !scope_step() {
573                return None;
574            }
575        }
576        let Some(details) = python_import_details(import) else {
577            continue;
578        };
579        let mut binding_scope = None;
580        let mut valid = true;
581        for scope in path.lexical_scopes.iter().rev() {
582            if !scope_step() {
583                return None;
584            }
585            let Some(node) = root.descendant_for_byte_range(scope.start_byte, scope.end_byte)
586            else {
587                valid = false;
588                break;
589            };
590            if node.start_byte() != scope.start_byte || node.end_byte() != scope.end_byte {
591                valid = false;
592                break;
593            }
594            if matches!(node.kind(), "function_definition" | "lambda") {
595                binding_scope = Some((scope.start_byte, scope.end_byte, true));
596                break;
597            }
598        }
599        if !valid {
600            continue;
601        }
602        let (scope_start_byte, scope_end_byte, function_scoped) =
603            binding_scope.unwrap_or((0, source_len, false));
604        let Some(local_name) = import.identifier.clone() else {
605            continue;
606        };
607        let (qualified_name, consumed_attributes) = match details {
608            PythonImportDetails::Import { module, alias } => (
609                module,
610                if alias.is_some() {
611                    0
612                } else {
613                    path.segments.len().saturating_sub(1)
614                },
615            ),
616            PythonImportDetails::FromImport {
617                module,
618                name,
619                wildcard: false,
620                ..
621            } => (format!("{module}.{name}"), 0),
622            PythonImportDetails::FromImport { wildcard: true, .. } => continue,
623        };
624        bindings.push(PythonImportBinding {
625            start_byte: path.declaration_start_byte,
626            scope_start_byte,
627            scope_end_byte,
628            function_scoped,
629            local_name,
630            qualified_name,
631            consumed_attributes,
632        });
633    }
634    bindings.sort_by_key(|binding| binding.start_byte);
635    Some(bindings)
636}
637
638fn python_import_binding_scope(node: Node<'_>, source_len: usize) -> (usize, usize, bool) {
639    let mut parent = node.parent();
640    while let Some(scope) = parent {
641        if matches!(scope.kind(), "function_definition" | "lambda") {
642            return (scope.start_byte(), scope.end_byte(), true);
643        }
644        parent = scope.parent();
645    }
646    (0, source_len, false)
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use brokk_bifrost_core::analyzer::usages::model::ImportBinder;
653    use brokk_bifrost_test_macros::hardwon;
654
655    fn importlib_assignment_for(source: &str, importlib_local: &str) -> Option<String> {
656        let tree = parse_python_tree(source).expect("valid Python fixture");
657        let reference_start = source.rfind("canonical.setup").expect("reference site");
658        let mut stack = vec![tree.root_node()];
659        let mut reference = None;
660        while let Some(node) = stack.pop() {
661            if node.kind() == "identifier"
662                && node.start_byte() == reference_start
663                && node_source_text(node, source) == "canonical"
664            {
665                reference = Some(node);
666                break;
667            }
668            let mut cursor = node.walk();
669            stack.extend(node.named_children(&mut cursor));
670        }
671        imported_module_assignment_at(
672            reference.expect("canonical reference node"),
673            "canonical",
674            source,
675            |local| local == importlib_local,
676        )
677    }
678
679    #[test]
680    fn importlib_assignment_requires_a_direct_literal_dominating_write() {
681        let valid = r#"import importlib as loader
682
683def test():
684    canonical = loader.import_module("routes.contacts.contacts_routes")
685    assert canonical.setup_contacts_routes
686"#;
687        assert_eq!(
688            importlib_assignment_for(valid, "loader"),
689            Some("routes.contacts.contacts_routes".to_string())
690        );
691
692        for near_miss in [
693            r#"import importlib as loader
694def test(module_name):
695    canonical = loader.import_module(module_name)
696    assert canonical.setup_contacts_routes
697"#,
698            r#"import importlib as loader
699def test():
700    if enabled:
701        canonical = loader.import_module("routes.contacts.contacts_routes")
702    assert canonical.setup_contacts_routes
703"#,
704            r#"import importlib as loader
705def test():
706    canonical = loader.import_module("routes.contacts.contacts_routes")
707    canonical = object()
708    assert canonical.setup_contacts_routes
709"#,
710        ] {
711            assert_eq!(
712                importlib_assignment_for(near_miss, "loader"),
713                None,
714                "near miss must not infer a module receiver: {near_miss:?}"
715            );
716        }
717        assert_eq!(
718            importlib_assignment_for(valid, "shadowed_loader"),
719            None,
720            "a similarly shaped non-importlib receiver must not resolve"
721        );
722    }
723
724    fn replacement_for(source: &str, binder: &ImportBinder) -> Option<PythonModuleReplacement> {
725        let tree = parse_python_tree(source).expect("valid Python fixture");
726        let root = tree.root_node();
727        let mut replacement = None;
728        let mut cursor = root.walk();
729        for statement in root.named_children(&mut cursor) {
730            let statement = if statement.kind() == "expression_statement" {
731                statement.named_child(0).expect("fixture expression")
732            } else {
733                statement
734            };
735            if statement.kind() == "assignment"
736                && let Some(next) =
737                    module_replacement_from_assignment(statement, source, &binder.bindings)
738                && replacement.replace(next).is_some()
739            {
740                return None;
741            }
742        }
743        replacement
744    }
745
746    #[test]
747    #[hardwon("59dfb80c3bb8: module replacement accepted malformed or shadowed import shapes")]
748    fn module_replacement_requires_exact_structured_import_bindings() {
749        let source = r#"import sys as _sys
750from routes.contacts import contacts_routes as _canonical
751
752_sys.modules[__name__] = _canonical
753"#;
754        let mut binder = ImportBinder::empty();
755        binder.bindings.insert(
756            "_sys".to_string(),
757            ImportBinding {
758                module_specifier: "sys".to_string(),
759                namespace_imported_module: Some("sys".to_string()),
760                kind: ImportKind::Namespace,
761                imported_name: None,
762            },
763        );
764        binder.bindings.insert(
765            "_canonical".to_string(),
766            ImportBinding {
767                module_specifier: "routes.contacts.contacts_routes".to_string(),
768                namespace_imported_module: None,
769                kind: ImportKind::Namespace,
770                imported_name: None,
771            },
772        );
773
774        assert_eq!(
775            replacement_for(source, &binder),
776            Some(PythonModuleReplacement {
777                target_module: "routes.contacts.contacts_routes".to_string(),
778            })
779        );
780
781        for near_miss in [
782            "cache.modules[__name__] = _canonical\n",
783            "_sys.modules[module_name] = _canonical\n",
784            "_sys.modules[__name__] = build()\n",
785            "def replace():\n    _sys.modules[__name__] = _canonical\n",
786        ] {
787            assert_eq!(
788                replacement_for(near_miss, &binder),
789                None,
790                "near miss must not replace module identity: {near_miss:?}"
791            );
792        }
793    }
794
795    #[test]
796    #[hardwon(
797        "b58235b247fd: Python import bindings lost function scope or relative module identity"
798    )]
799    fn existing_tree_import_bindings_preserve_function_scope_and_relative_identity() {
800        let source = r#"def register():
801    from .workers.special import Worker
802    return Worker()
803"#;
804        let tree = parse_python_tree(source).expect("Python tree");
805        let bindings = python_import_bindings_from_tree(tree.root_node(), source);
806
807        assert_eq!(bindings.len(), 1, "{bindings:#?}");
808        assert!(bindings[0].is_function_scoped(), "{bindings:#?}");
809        assert_eq!(bindings[0].local_name, "Worker");
810        assert_eq!(bindings[0].qualified_name, ".workers.special.Worker");
811        assert!(bindings[0].scope_start_byte <= bindings[0].start_byte);
812        assert!(bindings[0].scope_end_byte >= source.rfind("Worker()").unwrap());
813    }
814}
815
816/// Borrow each statement's contiguous canonical leaves without reinterpreting
817/// target or alias syntax in executable binding timelines.
818pub(crate) fn python_imports_by_statement(imports: &[ImportInfo]) -> HashMap<usize, &[ImportInfo]> {
819    let mut statement_imports = HashMap::default();
820    for group in imports.chunk_by(|left, right| {
821        left.path.as_ref().map(|path| path.declaration_start_byte)
822            == right.path.as_ref().map(|path| path.declaration_start_byte)
823    }) {
824        if let Some(path) = &group[0].path {
825            assert!(
826                statement_imports
827                    .insert(path.declaration_start_byte, group)
828                    .is_none(),
829                "canonical Python import leaves must remain in statement order"
830            );
831        }
832    }
833    statement_imports
834}
835
836pub fn module_replacement_of(
837    python: &dyn PythonSource,
838    file: &ProjectFile,
839    source: &str,
840    imports: &[ImportInfo],
841) -> Option<PythonModuleReplacement> {
842    let tree = parse_python_tree(source)?;
843    let root = tree.root_node();
844    let mut bindings: HashMap<String, ImportBinding> = HashMap::default();
845    let statement_imports = python_imports_by_statement(imports);
846    let mut replacement = None;
847    let mut cursor = root.walk();
848    for statement in root.named_children(&mut cursor) {
849        let statement = if statement.kind() == "expression_statement" {
850            let Some(expression) = statement.named_child(0) else {
851                continue;
852            };
853            expression
854        } else {
855            statement
856        };
857        match statement.kind() {
858            "import_statement" | "import_from_statement" => {
859                if let Some(imports) = statement_imports.get(&statement.start_byte()) {
860                    bindings.extend(import_binder_from_imports(python, file, imports).bindings);
861                }
862            }
863            "assignment" => {
864                if let Some(next) = module_replacement_from_assignment(statement, source, &bindings)
865                    && replacement.replace(next).is_some()
866                {
867                    return None;
868                }
869                remove_direct_scope_bindings(statement, source, &mut bindings);
870            }
871            _ => remove_direct_scope_bindings(statement, source, &mut bindings),
872        }
873    }
874    replacement
875}
876
877pub fn resolve_import_bindings(
878    python: &dyn PythonSource,
879    token: QueryToken<'_>,
880    file: &ProjectFile,
881) -> HashMap<String, CodeUnit> {
882    let imports = python.import_info_of(token, file);
883    let mut bindings = HashMap::default();
884    for resolved in resolve_imports_batched(python, file, &imports) {
885        for (binding, code_unit) in resolved {
886            bindings.insert(binding, code_unit);
887        }
888    }
889    bindings
890}
891
892/// Resolves every import in `imports` (`file`'s own imports), batching each import's primary
893/// module FQN lookup (see `primary_module_fqn`) into one store transaction instead of one per
894/// import. Shared by `resolve_import_bindings` and `resolve_import_target_files`, the two per-file
895/// "resolve everything" entry points -- both are called once per candidate file by the usages
896/// candidate walker, so unbatched resolution here means one store transaction per import times
897/// every file in the workspace.
898pub fn resolve_imports_batched(
899    python: &dyn PythonSource,
900    file: &ProjectFile,
901    imports: &[ImportInfo],
902) -> Vec<Vec<(String, CodeUnit)>> {
903    let primary_fqns: Vec<Option<String>> = imports
904        .iter()
905        .map(|import| primary_module_fqn(python, file, import))
906        .collect();
907    let to_resolve: Vec<String> = primary_fqns.iter().flatten().cloned().collect();
908    let mut batch_results =
909        resolve_module_code_units_batch(python, Some(file), &to_resolve).into_iter();
910
911    imports
912        .iter()
913        .zip(primary_fqns.iter())
914        .map(|(import, primary_fqn)| {
915            let hint = primary_fqn.as_ref().map(|_| batch_results.next().unwrap());
916            resolve_import_with_hint(python, file, import, hint.as_ref())
917        })
918        .collect()
919}
920
921/// Resolve Python imports to the workspace files whose modules they execute.
922///
923/// A file dependency graph does not need declaration binding: for
924/// `from pkg import member`, the dependency on `pkg` exists whether `member`
925/// is a local declaration, a re-export, or an optional external name. When
926/// `member` is itself the workspace submodule `pkg.member`, that file is a
927/// dependency too. Resolve both module spellings from the path-backed module
928/// index in one batch and deliberately avoid the exact-definition fallbacks
929/// used by [`resolve_imports_batched`].
930pub fn resolve_import_files_batched(
931    python: &dyn PythonSource,
932    file: &ProjectFile,
933    imports: &[ImportInfo],
934) -> HashSet<ProjectFile> {
935    let mut module_fqns = Vec::new();
936    for import in imports {
937        match python_import_details(import) {
938            Some(PythonImportDetails::Import { module, .. }) => module_fqns.push(module),
939            Some(PythonImportDetails::FromImport {
940                module,
941                name,
942                wildcard,
943                ..
944            }) => {
945                let resolved_module = if module.starts_with('.') {
946                    resolve_python_relative_module(python.project(), file, &module)
947                } else {
948                    Some(module)
949                };
950                let Some(resolved_module) = resolved_module else {
951                    continue;
952                };
953                if !wildcard {
954                    module_fqns.push(format!("{resolved_module}.{name}"));
955                }
956                module_fqns.push(resolved_module);
957            }
958            None => {}
959        }
960    }
961    module_fqns.sort();
962    module_fqns.dedup();
963
964    python
965        .path_module_fqns_batch(&module_fqns)
966        .into_iter()
967        .flatten()
968        .flatten()
969        .filter(CodeUnit::is_module)
970        .map(|unit| unit.source().clone())
971        .collect()
972}
973
974/// The module FQN fast path checks first, if any -- must stay in sync with the
975/// two `resolve_module_code_unit` call sites in `resolve_import_with_hint` below, since it's what
976/// lets `resolve_import_target_files` batch-resolve them ahead of the serial fallback logic.
977fn primary_module_fqn(
978    python: &dyn PythonSource,
979    file: &ProjectFile,
980    import: &ImportInfo,
981) -> Option<String> {
982    match python_import_details(import)? {
983        PythonImportDetails::Import { module, alias } => Some(python_namespace_binding_module(
984            import,
985            alias.as_deref(),
986            &module,
987        )),
988        PythonImportDetails::FromImport {
989            module,
990            name,
991            wildcard,
992            ..
993        } => {
994            if wildcard {
995                return None;
996            }
997            let resolved_module = resolve_python_module_specifier(python.project(), file, &module);
998            resolved_module.map(|resolved_module| format!("{resolved_module}.{name}"))
999        }
1000    }
1001}
1002
1003/// `primary_hint`, when `Some`, is the already-resolved result of this import's primary module FQN
1004/// (see `primary_module_fqn`) so the batched caller doesn't pay for a second lookup of the same FQN.
1005fn resolve_import_with_hint(
1006    python: &dyn PythonSource,
1007    file: &ProjectFile,
1008    import: &ImportInfo,
1009    primary_hint: Option<&Option<CodeUnit>>,
1010) -> Vec<(String, CodeUnit)> {
1011    if let Some(details) = python_import_details(import) {
1012        match details {
1013            PythonImportDetails::Import { module, alias } => {
1014                let binding = python_namespace_binding_name(import, alias.as_deref(), &module);
1015                let bound_module =
1016                    python_namespace_binding_module(import, alias.as_deref(), &module);
1017                let resolved = match primary_hint {
1018                    Some(hint) => hint.clone(),
1019                    None => resolve_module_code_unit(python, Some(file), &bound_module),
1020                };
1021                if let Some(module_code_unit) = resolved {
1022                    return vec![(binding, module_code_unit)];
1023                }
1024            }
1025            PythonImportDetails::FromImport {
1026                module,
1027                name,
1028                alias,
1029                wildcard,
1030            } => {
1031                let resolved_module =
1032                    resolve_python_module_specifier(python.project(), file, &module);
1033                let Some(resolved_module) = resolved_module else {
1034                    return Vec::new();
1035                };
1036                if wildcard {
1037                    return public_declarations_in_module(python, file, &resolved_module)
1038                        .into_iter()
1039                        .map(|code_unit| (code_unit.identifier().to_string(), code_unit))
1040                        .collect();
1041                }
1042
1043                let binding = alias.clone().unwrap_or_else(|| name.clone());
1044                let module_candidate = format!("{resolved_module}.{name}");
1045                let exported =
1046                    resolve_exported_name_from_module(python, Some(file), &resolved_module, &name);
1047                if !exported.is_empty() {
1048                    return exported
1049                        .into_iter()
1050                        .map(|code_unit| (binding.clone(), code_unit))
1051                        .collect();
1052                }
1053                let resolved = match primary_hint {
1054                    Some(hint) => hint.clone(),
1055                    None => resolve_module_code_unit(python, Some(file), &module_candidate),
1056                };
1057                if let Some(code_unit) = resolved {
1058                    return vec![(binding, code_unit)];
1059                }
1060                let definitions: Vec<_> = python.definitions(&module_candidate).collect();
1061                if !definitions.is_empty() {
1062                    return definitions
1063                        .into_iter()
1064                        .map(|code_unit| (binding.clone(), code_unit))
1065                        .collect();
1066                }
1067                let package_candidate: Vec<_> = python
1068                    .definitions(&format!("{resolved_module}.{name}"))
1069                    .collect();
1070                if !package_candidate.is_empty() {
1071                    return package_candidate
1072                        .into_iter()
1073                        .map(|code_unit| (binding.clone(), code_unit))
1074                        .collect();
1075                }
1076            }
1077        }
1078    }
1079    Vec::new()
1080}
1081
1082/// `importer`, when known, is the file whose import statement asked for `fqn`;
1083/// its own import root is what decides between two same-named modules. `None`
1084/// is for an FQN-level question no import statement asked.
1085pub fn resolve_exported_fqn(
1086    python: &dyn PythonSource,
1087    importer: Option<&ProjectFile>,
1088    fqn: &str,
1089) -> Vec<CodeUnit> {
1090    let Some((module, name)) = fqn.rsplit_once('.') else {
1091        return Vec::new();
1092    };
1093    resolve_exported_name_from_module(python, importer, module, name)
1094}
1095
1096/// Resolve an unambiguous chain of explicit named reexports without
1097/// constructing export indexes for each intermediate module. Star exports,
1098/// shadowing, and every other ambiguous shape return `None` so callers can
1099/// use the complete, source-order-aware export resolver below.
1100///
1101/// Each hop carries its own importer: the first is the file that asked, and
1102/// every later one is the module whose reexport named the next specifier, so
1103/// a facade chain stays inside the roots that actually wrote it.
1104fn resolve_direct_named_exported_fqn(
1105    python: &dyn PythonSource,
1106    importer: Option<&ProjectFile>,
1107    fqn: &str,
1108) -> Option<Vec<CodeUnit>> {
1109    let (module, name) = fqn.rsplit_once('.')?;
1110    let mut results = Vec::new();
1111    let mut queue = VecDeque::from([(importer.cloned(), module.to_string(), name.to_string())]);
1112    let mut visited = HashSet::default();
1113
1114    while let Some((importer, module, export_name)) = queue.pop_front() {
1115        if !visited.insert((module.clone(), export_name.clone())) {
1116            continue;
1117        }
1118        let module_unit = resolve_module_code_unit(python, importer.as_ref(), &module)?;
1119        let file = module_unit.source();
1120        let local = local_export_declarations(python, file, &export_name);
1121        let binder = python.import_binder_of(file);
1122        let binding = binder.bindings.get(&export_name);
1123        if !local.is_empty() && binding.is_some() {
1124            return None;
1125        }
1126        if !local.is_empty() {
1127            results.extend(local);
1128            continue;
1129        }
1130        let binding = binding?;
1131        if binding.kind != ImportKind::Named {
1132            return None;
1133        }
1134        let imported_name = binding.imported_name.as_ref()?;
1135        queue.push_back((
1136            Some(file.clone()),
1137            binding.module_specifier.clone(),
1138            imported_name.clone(),
1139        ));
1140    }
1141
1142    results.sort_by(|left, right| {
1143        left.source()
1144            .cmp(right.source())
1145            .then_with(|| left.fq_name().cmp(&right.fq_name()))
1146    });
1147    results.dedup();
1148    (!results.is_empty()).then_some(results)
1149}
1150
1151/// Resolve a Python FQN with the cheapest semantically complete tier that
1152/// can answer it. The direct reexport walk handles only proven,
1153/// collision-free chains; ambiguous shapes use the ordered export index,
1154/// and the exact lookup remains the final fallback for non-export symbols.
1155///
1156/// The first two tiers resolve a module specifier, so they take `importer`
1157/// for the same reason the import resolver does. The exact lookup is a
1158/// workspace-wide question about a dotted name and stays root-blind.
1159pub fn resolve_fqn_candidates(
1160    python: &dyn PythonSource,
1161    importer: Option<&ProjectFile>,
1162    fqn: &str,
1163    exact: impl FnOnce(&str) -> Vec<CodeUnit>,
1164) -> Vec<CodeUnit> {
1165    if let Some(candidates) = resolve_direct_named_exported_fqn(python, importer, fqn) {
1166        return candidates;
1167    }
1168    let candidates = resolve_exported_fqn(python, importer, fqn);
1169    if !candidates.is_empty() {
1170        return candidates;
1171    }
1172    exact(fqn)
1173}
1174
1175fn resolve_exported_name_from_module(
1176    python: &dyn PythonSource,
1177    importer: Option<&ProjectFile>,
1178    module: &str,
1179    name: &str,
1180) -> Vec<CodeUnit> {
1181    let Some(module_unit) = resolve_module_code_unit(python, importer, module) else {
1182        return Vec::new();
1183    };
1184    resolve_exported_name(python, module_unit.source(), name)
1185}
1186
1187fn resolve_exported_name(
1188    python: &dyn PythonSource,
1189    module_file: &ProjectFile,
1190    name: &str,
1191) -> Vec<CodeUnit> {
1192    let mut results = Vec::new();
1193    let mut queue = VecDeque::from([(module_file.clone(), name.to_string())]);
1194    let mut visited = HashSet::default();
1195
1196    while let Some((file, export_name)) = queue.pop_front() {
1197        if !visited.insert((file.clone(), export_name.clone())) {
1198            continue;
1199        }
1200
1201        let index = python.export_index_of(&file);
1202        if let Some(entry) = index.exports_by_name.get(&export_name) {
1203            match entry {
1204                ExportEntry::Local { local_name } => {
1205                    results.extend(local_export_declarations(python, &file, local_name));
1206                }
1207                ExportEntry::ReexportedNamed {
1208                    module_specifier,
1209                    imported_name,
1210                } => {
1211                    for target_file in
1212                        resolve_module_files_for_export(python, &file, module_specifier)
1213                    {
1214                        queue.push_back((target_file, imported_name.clone()));
1215                    }
1216                }
1217                ExportEntry::ReexportedModule { module_specifier } => {
1218                    // Terminal: the export *is* the module, so the walk stops
1219                    // here instead of looking the name up inside it.
1220                    results.extend(resolve_module_code_unit(
1221                        python,
1222                        Some(&file),
1223                        module_specifier,
1224                    ));
1225                }
1226                ExportEntry::Default { local_name } => {
1227                    if let Some(local_name) = local_name {
1228                        results.extend(local_export_declarations(python, &file, local_name));
1229                    }
1230                }
1231            }
1232            continue;
1233        }
1234
1235        if !export_name.starts_with('_') {
1236            for star in &index.reexport_stars {
1237                for target_file in
1238                    resolve_module_files_for_export(python, &file, &star.module_specifier)
1239                {
1240                    queue.push_back((target_file, export_name.clone()));
1241                }
1242            }
1243        }
1244    }
1245
1246    results.sort_by(|left, right| {
1247        left.source()
1248            .cmp(right.source())
1249            .then_with(|| left.fq_name().cmp(&right.fq_name()))
1250    });
1251    results.dedup();
1252    results
1253}
1254
1255fn local_export_declarations(
1256    index: &dyn CodeUnitIndex,
1257    file: &ProjectFile,
1258    local_name: &str,
1259) -> Vec<CodeUnit> {
1260    index
1261        .top_level_declarations(file)
1262        .into_iter()
1263        .filter(|unit| {
1264            unit.identifier() == local_name
1265                && index
1266                    .parent_of(unit)
1267                    .is_some_and(|parent| parent.is_module() && parent.source() == file)
1268        })
1269        .collect()
1270}
1271
1272fn resolve_module_files_for_export(
1273    python: &dyn PythonSource,
1274    importing_file: &ProjectFile,
1275    module_specifier: &str,
1276) -> Vec<ProjectFile> {
1277    let resolved_module =
1278        resolve_python_module_specifier(python.project(), importing_file, module_specifier);
1279    let Some(resolved_module) = resolved_module else {
1280        return Vec::new();
1281    };
1282    // Tree-sitter tells us the import syntax, but module-to-file resolution
1283    // is analyzer state. Use the prebuilt module code-unit map here instead
1284    // of the usage index so interactive definition lookup stays lightweight.
1285    resolve_module_code_unit(python, Some(importing_file), &resolved_module)
1286        .map(|unit| vec![unit.source().clone()])
1287        .unwrap_or_default()
1288}
1289
1290pub fn extract_package_from_python_wildcard(import: &ImportInfo) -> Option<String> {
1291    let details = python_import_details(import)?;
1292    match details {
1293        PythonImportDetails::FromImport {
1294            module, wildcard, ..
1295        } if wildcard => Some(module),
1296        _ => None,
1297    }
1298}
1299
1300#[derive(Debug, Clone)]
1301pub enum PythonImportDetails {
1302    Import {
1303        module: String,
1304        alias: Option<String>,
1305    },
1306    FromImport {
1307        module: String,
1308        name: String,
1309        alias: Option<String>,
1310        wildcard: bool,
1311    },
1312}
1313
1314/// One import leaf interpreted directly from the Python AST.
1315///
1316/// The generic [`ImportInfo`] remains the display/query projection. The
1317/// source nodes are retained beside it so the coordinated primary producer can
1318/// intern declaration, binder, and alias occurrences without matching names or
1319/// byte ranges after the tree has been walked.
1320#[derive(Debug, Clone)]
1321pub struct PythonImportSyntax<'tree> {
1322    pub info: ImportInfo,
1323    pub module: Node<'tree>,
1324    pub member: Option<Node<'tree>>,
1325    pub leaf: Node<'tree>,
1326    pub declaration: Node<'tree>,
1327    pub target: Option<Node<'tree>>,
1328    pub alias: Option<Node<'tree>>,
1329}
1330
1331pub fn python_import_infos_from_node(node: Node<'_>, source: &str) -> Vec<ImportInfo> {
1332    python_import_syntaxes_from_node(node, source)
1333        .into_iter()
1334        .map(|syntax| syntax.info)
1335        .collect()
1336}
1337
1338/// Interpret every import leaf in one statement and retain its exact AST
1339/// binder nodes. Malformed leaves are omitted with the same admission rules as
1340/// the historical generic import projection.
1341pub fn python_import_syntaxes_from_node<'tree>(
1342    node: Node<'tree>,
1343    source: &str,
1344) -> Vec<PythonImportSyntax<'tree>> {
1345    match node.kind() {
1346        "import_statement" => python_namespace_import_syntaxes(node, source),
1347        "import_from_statement" => python_from_import_syntaxes(node, source),
1348        _ => Vec::new(),
1349    }
1350}
1351
1352pub fn python_import_details(import: &ImportInfo) -> Option<PythonImportDetails> {
1353    let path = import.path.as_ref()?;
1354    match path.kind? {
1355        StructuredImportPathKind::Namespace => Some(PythonImportDetails::Import {
1356            module: join_python_import_segments(&path.segments),
1357            alias: import.alias.clone(),
1358        }),
1359        // These declaration forms belong to Java and Rust, not Python.
1360        StructuredImportPathKind::StaticMember | StructuredImportPathKind::ExternCrate => None,
1361        // PHP include kinds are not Python import statements.
1362        StructuredImportPathKind::FileInclude | StructuredImportPathKind::DynamicInclude => None,
1363        StructuredImportPathKind::ImportFrom => {
1364            let (name, module_segments) = if import.is_wildcard {
1365                ("*".to_string(), path.segments.as_slice())
1366            } else {
1367                let (name, module_segments) = path.segments.split_last()?;
1368                (name.clone(), module_segments)
1369            };
1370            Some(PythonImportDetails::FromImport {
1371                module: join_python_import_segments(module_segments),
1372                name,
1373                alias: import.alias.clone(),
1374                wildcard: import.is_wildcard,
1375            })
1376        }
1377    }
1378}
1379
1380fn python_namespace_import_syntaxes<'tree>(
1381    node: Node<'tree>,
1382    source: &str,
1383) -> Vec<PythonImportSyntax<'tree>> {
1384    let mut infos = Vec::new();
1385    let mut cursor = node.walk();
1386    for imported in node.children_by_field_name("name", &mut cursor) {
1387        let (module_node, alias_node) = if imported.kind() == "aliased_import" {
1388            let Some(name) = imported.child_by_field_name("name") else {
1389                continue;
1390            };
1391            (name, imported.child_by_field_name("alias"))
1392        } else {
1393            (imported, None)
1394        };
1395        let alias = alias_node
1396            .map(|alias| py_node_text(alias, source).trim().to_string())
1397            .filter(|alias| !alias.is_empty());
1398        let segments = python_path_segments(module_node, source);
1399        if segments.is_empty() {
1400            continue;
1401        }
1402        // `import a.b` binds `a`: the first segment's own token. A renamed
1403        // import binds its alias token instead.
1404        let target = alias
1405            .is_none()
1406            .then(|| python_first_segment_node(module_node))
1407            .flatten();
1408        let binder_span = alias_node
1409            .or(target)
1410            .map(brokk_bifrost_core::analyzer::common::node_span);
1411        let module = join_python_import_segments(&segments);
1412        let identifier = alias.clone().or_else(|| segments.first().cloned());
1413        let info = ImportInfo {
1414            raw_snippet: if let Some(alias) = &alias {
1415                format!("import {module} as {alias}")
1416            } else {
1417                format!("import {module}")
1418            },
1419            is_wildcard: false,
1420            is_global: false,
1421            identifier,
1422            alias,
1423            path: Some(StructuredImportPath {
1424                segments,
1425                kind: Some(StructuredImportPathKind::Namespace),
1426                lexical_prefixes: Vec::new(),
1427                lexical_scopes: Vec::new(),
1428                declaration_start_byte: node.start_byte(),
1429            }),
1430            binder_span,
1431        };
1432        infos.push(PythonImportSyntax {
1433            info,
1434            module: module_node,
1435            member: None,
1436            leaf: imported,
1437            declaration: node,
1438            target,
1439            alias: alias_node,
1440        });
1441    }
1442    infos
1443}
1444
1445fn python_from_import_syntaxes<'tree>(
1446    node: Node<'tree>,
1447    source: &str,
1448) -> Vec<PythonImportSyntax<'tree>> {
1449    let Some(module_node) = node.child_by_field_name("module_name") else {
1450        return Vec::new();
1451    };
1452    let module_segments = python_module_segments(module_node, source);
1453    if module_segments.is_empty() {
1454        return Vec::new();
1455    }
1456
1457    let mut infos = Vec::new();
1458    let has_wildcard_import = {
1459        let mut cursor = node.walk();
1460        node.named_children(&mut cursor)
1461            .any(|child| child.kind() == "wildcard_import")
1462    };
1463    let mut cursor = node.walk();
1464    let imported_names: Vec<_> = node.children_by_field_name("name", &mut cursor).collect();
1465    if has_wildcard_import {
1466        let module = join_python_import_segments(&module_segments);
1467        let info = ImportInfo {
1468            raw_snippet: format!("from {module} import *"),
1469            is_wildcard: true,
1470            is_global: false,
1471            identifier: None,
1472            alias: None,
1473            path: Some(StructuredImportPath {
1474                segments: module_segments,
1475                kind: Some(StructuredImportPathKind::ImportFrom),
1476                lexical_prefixes: Vec::new(),
1477                lexical_scopes: Vec::new(),
1478                declaration_start_byte: node.start_byte(),
1479            }),
1480            binder_span: None,
1481        };
1482        infos.push(PythonImportSyntax {
1483            info,
1484            module: module_node,
1485            member: None,
1486            leaf: node,
1487            declaration: node,
1488            target: None,
1489            alias: None,
1490        });
1491        return infos;
1492    }
1493    if imported_names.is_empty() {
1494        return infos;
1495    }
1496
1497    for imported in imported_names {
1498        let (name_node, alias_node) = if imported.kind() == "aliased_import" {
1499            let Some(name) = imported.child_by_field_name("name") else {
1500                continue;
1501            };
1502            (name, imported.child_by_field_name("alias"))
1503        } else {
1504            (imported, None)
1505        };
1506        let alias = alias_node
1507            .map(|alias| py_node_text(alias, source).trim().to_string())
1508            .filter(|alias| !alias.is_empty());
1509        let name_segments = python_path_segments(name_node, source);
1510        if name_segments.is_empty() {
1511            continue;
1512        }
1513        // `from m import x` binds `x`'s own token; a rename binds the alias
1514        // token. A multi-segment imported name binds no single token.
1515        let target = alias
1516            .is_none()
1517            .then(|| {
1518                (name_segments.len() == 1)
1519                    .then(|| python_first_segment_node(name_node))
1520                    .flatten()
1521            })
1522            .flatten();
1523        let binder_span = alias_node
1524            .or(target)
1525            .map(brokk_bifrost_core::analyzer::common::node_span);
1526        let imported_name = join_python_import_segments(&name_segments);
1527        let mut segments = module_segments.clone();
1528        segments.extend(name_segments);
1529        let module = join_python_import_segments(&module_segments);
1530        let info = ImportInfo {
1531            raw_snippet: if let Some(alias) = &alias {
1532                format!("from {module} import {imported_name} as {alias}")
1533            } else {
1534                format!("from {module} import {imported_name}")
1535            },
1536            is_wildcard: false,
1537            is_global: false,
1538            identifier: Some(alias.clone().unwrap_or_else(|| imported_name.clone())),
1539            alias,
1540            path: Some(StructuredImportPath {
1541                segments,
1542                kind: Some(StructuredImportPathKind::ImportFrom),
1543                lexical_prefixes: Vec::new(),
1544                lexical_scopes: Vec::new(),
1545                declaration_start_byte: node.start_byte(),
1546            }),
1547            binder_span,
1548        };
1549        infos.push(PythonImportSyntax {
1550            info,
1551            module: module_node,
1552            member: Some(name_node),
1553            leaf: imported,
1554            declaration: node,
1555            target,
1556            alias: alias_node,
1557        });
1558    }
1559    infos
1560}
1561
1562fn python_module_segments(module: Node<'_>, source: &str) -> Vec<String> {
1563    if module.kind() == "relative_import" {
1564        let mut cursor = module.walk();
1565        let mut prefix = String::new();
1566        let mut path_node = None;
1567        for child in module.named_children(&mut cursor) {
1568            match child.kind() {
1569                "import_prefix" if prefix.is_empty() => {
1570                    prefix = py_node_text(child, source).trim().to_string();
1571                }
1572                "dotted_name" if path_node.is_none() => {
1573                    path_node = Some(child);
1574                }
1575                _ => {}
1576            }
1577        }
1578        let mut segments = path_node
1579            .map(|path| python_path_segments(path, source))
1580            .unwrap_or_default();
1581        if !prefix.is_empty() {
1582            if let Some(first) = segments.first_mut() {
1583                first.insert_str(0, &prefix);
1584            } else {
1585                segments.push(prefix);
1586            }
1587        }
1588        return segments;
1589    }
1590    python_path_segments(module, source)
1591}
1592
1593/// The token that spells a path's first segment: the identifier itself, or a
1594/// dotted name's first identifier. `None` when the shape has no leading
1595/// identifier token of its own (e.g. a relative-import prefix).
1596fn python_first_segment_node(node: Node<'_>) -> Option<Node<'_>> {
1597    match node.kind() {
1598        "identifier" => Some(node),
1599        "dotted_name" => {
1600            let mut cursor = node.walk();
1601            node.named_children(&mut cursor)
1602                .find(|child| child.kind() == "identifier")
1603        }
1604        _ => None,
1605    }
1606}
1607
1608/// Identifier components selected from an import path AST, in source order.
1609/// Relative punctuation is deliberately not a name component.
1610pub(crate) fn python_import_identifier_nodes(node: Node<'_>) -> Vec<Node<'_>> {
1611    use brokk_bifrost_core::analyzer::tree_walk::{WalkControl, walk_named_tree_preorder};
1612    let mut segments = Vec::new();
1613    walk_named_tree_preorder(node, true, |node| {
1614        if node.kind() == "identifier" {
1615            segments.push(node);
1616        }
1617        WalkControl::Continue
1618    });
1619    segments
1620}
1621
1622fn python_path_segments(node: Node<'_>, source: &str) -> Vec<String> {
1623    python_import_identifier_nodes(node)
1624        .into_iter()
1625        .map(|node| py_node_text(node, source).trim().to_string())
1626        .collect()
1627}
1628
1629fn join_python_import_segments(segments: &[String]) -> String {
1630    let Some((first, rest)) = segments.split_first() else {
1631        return String::new();
1632    };
1633    if first.starts_with('.') && !rest.is_empty() {
1634        format!("{first}.{}", rest.join("."))
1635    } else {
1636        segments.join(".")
1637    }
1638}
1639
1640pub fn python_namespace_binding_name(
1641    import: &ImportInfo,
1642    alias: Option<&str>,
1643    module: &str,
1644) -> String {
1645    import
1646        .identifier
1647        .clone()
1648        .or_else(|| alias.map(str::to_string))
1649        .unwrap_or_else(|| module.to_string())
1650}
1651
1652pub fn python_namespace_binding_module(
1653    import: &ImportInfo,
1654    alias: Option<&str>,
1655    module: &str,
1656) -> String {
1657    if alias.is_some() {
1658        return module.to_string();
1659    }
1660    import
1661        .path
1662        .as_ref()
1663        .and_then(|path| path.segments.first().cloned())
1664        .unwrap_or_else(|| module.to_string())
1665}
1666
1667pub fn resolve_python_relative_module(
1668    project: &dyn brokk_bifrost_core::analyzer::project::Project,
1669    source_file: &ProjectFile,
1670    module_expr: &str,
1671) -> Option<String> {
1672    resolve_python_relative_module_from_package(
1673        &python_current_package(project, source_file),
1674        module_expr,
1675    )
1676}
1677
1678/// Resolve a relative Python module specifier against the importing file.
1679pub fn resolve_python_module_specifier(
1680    project: &dyn brokk_bifrost_core::analyzer::project::Project,
1681    importing_file: &ProjectFile,
1682    module_specifier: &str,
1683) -> Option<String> {
1684    if module_specifier.starts_with('.') {
1685        resolve_python_relative_module(project, importing_file, module_specifier)
1686    } else {
1687        Some(module_specifier.to_string())
1688    }
1689}
1690
1691/// Resolve a structured Python module expression against an already-known
1692/// package identity. Dependency-pack producers know module identities without
1693/// owning a workspace [`ProjectFile`], so they use this entry point.
1694pub fn resolve_python_relative_module_from_package(
1695    current_package: &str,
1696    module_expr: &str,
1697) -> Option<String> {
1698    let level = module_expr.chars().take_while(|ch| *ch == '.').count();
1699    let suffix = module_expr[level..].trim_matches('.');
1700    let mut parts: Vec<_> = current_package
1701        .split('.')
1702        .filter(|part| !part.is_empty())
1703        .map(str::to_string)
1704        .collect();
1705    if level == 0 {
1706        return Some(module_expr.to_string());
1707    }
1708    if level > 0 {
1709        if level - 1 > parts.len() {
1710            return None;
1711        }
1712        parts.truncate(parts.len() - (level - 1));
1713    }
1714    if !suffix.is_empty() {
1715        parts.extend(suffix.split('.').map(str::to_string));
1716    }
1717    Some(parts.join("."))
1718}
1719
1720fn python_current_package(
1721    project: &dyn brokk_bifrost_core::analyzer::project::Project,
1722    source_file: &ProjectFile,
1723) -> String {
1724    let module = python_module_name(project, source_file);
1725    if source_file
1726        .rel_path()
1727        .file_name()
1728        .and_then(|name| name.to_str())
1729        == Some("__init__.py")
1730    {
1731        module
1732    } else {
1733        module
1734            .rsplit_once('.')
1735            .map(|(package, _)| package.to_string())
1736            .unwrap_or_default()
1737    }
1738}