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
387/// Build import bindings from the canonical generic import projection.
388///
389/// The producer has already interpreted each path and retained lexical scope
390/// byte ranges in the import facts. This adapter only maps those ranges back
391/// to the caller's prepared tree to recover the nearest function or lambda
392/// scope; it never reparses source or reconstructs import syntax.
393pub fn python_import_bindings_from_imports(
394    imports: &[ImportInfo],
395    root: Node<'_>,
396    source_len: usize,
397) -> Vec<PythonImportBinding> {
398    let mut bindings = imports
399        .iter()
400        .filter_map(|import| {
401            let path = import.path.as_ref()?;
402            let details = python_import_details(import)?;
403            let mut binding_scope = None;
404            for scope in path.lexical_scopes.iter().rev() {
405                let node = root.descendant_for_byte_range(scope.start_byte, scope.end_byte)?;
406                if node.start_byte() != scope.start_byte || node.end_byte() != scope.end_byte {
407                    return None;
408                }
409                if matches!(node.kind(), "function_definition" | "lambda") {
410                    binding_scope = Some((scope.start_byte, scope.end_byte, true));
411                    break;
412                }
413            }
414            let (scope_start_byte, scope_end_byte, function_scoped) =
415                binding_scope.unwrap_or((0, source_len, false));
416            let start_byte = path.declaration_start_byte;
417            match details {
418                PythonImportDetails::Import { module, alias } => {
419                    let consumed_attributes = if alias.is_some() {
420                        0
421                    } else {
422                        path.segments.len().saturating_sub(1)
423                    };
424                    Some(PythonImportBinding {
425                        start_byte,
426                        scope_start_byte,
427                        scope_end_byte,
428                        function_scoped,
429                        local_name: alias.or_else(|| path.segments.first().cloned())?,
430                        qualified_name: module,
431                        consumed_attributes,
432                    })
433                }
434                PythonImportDetails::FromImport {
435                    module,
436                    name,
437                    alias,
438                    wildcard: false,
439                } => Some(PythonImportBinding {
440                    start_byte,
441                    scope_start_byte,
442                    scope_end_byte,
443                    function_scoped,
444                    local_name: alias.unwrap_or_else(|| name.clone()),
445                    qualified_name: format!("{module}.{name}"),
446                    consumed_attributes: 0,
447                }),
448                PythonImportDetails::FromImport { wildcard: true, .. } => None,
449            }
450        })
451        .collect::<Vec<_>>();
452    bindings.sort_by_key(|binding| binding.start_byte);
453    bindings
454}
455
456fn python_import_binding_scope(node: Node<'_>, source_len: usize) -> (usize, usize, bool) {
457    let mut parent = node.parent();
458    while let Some(scope) = parent {
459        if matches!(scope.kind(), "function_definition" | "lambda") {
460            return (scope.start_byte(), scope.end_byte(), true);
461        }
462        parent = scope.parent();
463    }
464    (0, source_len, false)
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use brokk_bifrost_core::analyzer::usages::model::ImportBinder;
471
472    fn importlib_assignment_for(source: &str, importlib_local: &str) -> Option<String> {
473        let tree = parse_python_tree(source).expect("valid Python fixture");
474        let reference_start = source.rfind("canonical.setup").expect("reference site");
475        let mut stack = vec![tree.root_node()];
476        let mut reference = None;
477        while let Some(node) = stack.pop() {
478            if node.kind() == "identifier"
479                && node.start_byte() == reference_start
480                && node_source_text(node, source) == "canonical"
481            {
482                reference = Some(node);
483                break;
484            }
485            let mut cursor = node.walk();
486            stack.extend(node.named_children(&mut cursor));
487        }
488        imported_module_assignment_at(
489            reference.expect("canonical reference node"),
490            "canonical",
491            source,
492            |local| local == importlib_local,
493        )
494    }
495
496    #[test]
497    fn existing_tree_import_bindings_preserve_function_scope_and_relative_identity() {
498        let source = r#"def register():
499    from .workers.special import Worker
500    return Worker()
501"#;
502        let tree = parse_python_tree(source).expect("Python tree");
503        let bindings = python_import_bindings_from_tree(tree.root_node(), source);
504
505        assert_eq!(bindings.len(), 1, "{bindings:#?}");
506        assert!(bindings[0].is_function_scoped(), "{bindings:#?}");
507        assert_eq!(bindings[0].local_name, "Worker");
508        assert_eq!(bindings[0].qualified_name, ".workers.special.Worker");
509        assert!(bindings[0].scope_start_byte <= bindings[0].start_byte);
510        assert!(bindings[0].scope_end_byte >= source.rfind("Worker()").unwrap());
511    }
512
513    #[test]
514    fn importlib_assignment_requires_a_direct_literal_dominating_write() {
515        let valid = r#"import importlib as loader
516
517def test():
518    canonical = loader.import_module("routes.contacts.contacts_routes")
519    assert canonical.setup_contacts_routes
520"#;
521        assert_eq!(
522            importlib_assignment_for(valid, "loader"),
523            Some("routes.contacts.contacts_routes".to_string())
524        );
525
526        for near_miss in [
527            r#"import importlib as loader
528def test(module_name):
529    canonical = loader.import_module(module_name)
530    assert canonical.setup_contacts_routes
531"#,
532            r#"import importlib as loader
533def test():
534    if enabled:
535        canonical = loader.import_module("routes.contacts.contacts_routes")
536    assert canonical.setup_contacts_routes
537"#,
538            r#"import importlib as loader
539def test():
540    canonical = loader.import_module("routes.contacts.contacts_routes")
541    canonical = object()
542    assert canonical.setup_contacts_routes
543"#,
544        ] {
545            assert_eq!(
546                importlib_assignment_for(near_miss, "loader"),
547                None,
548                "near miss must not infer a module receiver: {near_miss:?}"
549            );
550        }
551        assert_eq!(
552            importlib_assignment_for(valid, "shadowed_loader"),
553            None,
554            "a similarly shaped non-importlib receiver must not resolve"
555        );
556    }
557
558    fn replacement_for(source: &str, binder: &ImportBinder) -> Option<PythonModuleReplacement> {
559        let tree = parse_python_tree(source).expect("valid Python fixture");
560        let root = tree.root_node();
561        let mut replacement = None;
562        let mut cursor = root.walk();
563        for statement in root.named_children(&mut cursor) {
564            let statement = if statement.kind() == "expression_statement" {
565                statement.named_child(0).expect("fixture expression")
566            } else {
567                statement
568            };
569            if statement.kind() == "assignment"
570                && let Some(next) =
571                    module_replacement_from_assignment(statement, source, &binder.bindings)
572                && replacement.replace(next).is_some()
573            {
574                return None;
575            }
576        }
577        replacement
578    }
579
580    #[test]
581    fn module_replacement_requires_exact_structured_import_bindings() {
582        let source = r#"import sys as _sys
583from routes.contacts import contacts_routes as _canonical
584
585_sys.modules[__name__] = _canonical
586"#;
587        let mut binder = ImportBinder::empty();
588        binder.bindings.insert(
589            "_sys".to_string(),
590            ImportBinding {
591                module_specifier: "sys".to_string(),
592                namespace_imported_module: Some("sys".to_string()),
593                kind: ImportKind::Namespace,
594                imported_name: None,
595            },
596        );
597        binder.bindings.insert(
598            "_canonical".to_string(),
599            ImportBinding {
600                module_specifier: "routes.contacts.contacts_routes".to_string(),
601                namespace_imported_module: None,
602                kind: ImportKind::Namespace,
603                imported_name: None,
604            },
605        );
606
607        assert_eq!(
608            replacement_for(source, &binder),
609            Some(PythonModuleReplacement {
610                target_module: "routes.contacts.contacts_routes".to_string(),
611            })
612        );
613
614        for near_miss in [
615            "cache.modules[__name__] = _canonical\n",
616            "_sys.modules[module_name] = _canonical\n",
617            "_sys.modules[__name__] = build()\n",
618            "def replace():\n    _sys.modules[__name__] = _canonical\n",
619        ] {
620            assert_eq!(
621                replacement_for(near_miss, &binder),
622                None,
623                "near miss must not replace module identity: {near_miss:?}"
624            );
625        }
626    }
627}
628
629/// Borrow each statement's contiguous canonical leaves without reinterpreting
630/// target or alias syntax in executable binding timelines.
631pub(crate) fn python_imports_by_statement(imports: &[ImportInfo]) -> HashMap<usize, &[ImportInfo]> {
632    let mut statement_imports = HashMap::default();
633    for group in imports.chunk_by(|left, right| {
634        left.path.as_ref().map(|path| path.declaration_start_byte)
635            == right.path.as_ref().map(|path| path.declaration_start_byte)
636    }) {
637        if let Some(path) = &group[0].path {
638            assert!(
639                statement_imports
640                    .insert(path.declaration_start_byte, group)
641                    .is_none(),
642                "canonical Python import leaves must remain in statement order"
643            );
644        }
645    }
646    statement_imports
647}
648
649pub fn module_replacement_of(
650    python: &dyn PythonSource,
651    file: &ProjectFile,
652    source: &str,
653    imports: &[ImportInfo],
654) -> Option<PythonModuleReplacement> {
655    let tree = parse_python_tree(source)?;
656    let root = tree.root_node();
657    let mut bindings: HashMap<String, ImportBinding> = HashMap::default();
658    let statement_imports = python_imports_by_statement(imports);
659    let mut replacement = None;
660    let mut cursor = root.walk();
661    for statement in root.named_children(&mut cursor) {
662        let statement = if statement.kind() == "expression_statement" {
663            let Some(expression) = statement.named_child(0) else {
664                continue;
665            };
666            expression
667        } else {
668            statement
669        };
670        match statement.kind() {
671            "import_statement" | "import_from_statement" => {
672                if let Some(imports) = statement_imports.get(&statement.start_byte()) {
673                    bindings.extend(import_binder_from_imports(python, file, imports).bindings);
674                }
675            }
676            "assignment" => {
677                if let Some(next) = module_replacement_from_assignment(statement, source, &bindings)
678                    && replacement.replace(next).is_some()
679                {
680                    return None;
681                }
682                remove_direct_scope_bindings(statement, source, &mut bindings);
683            }
684            _ => remove_direct_scope_bindings(statement, source, &mut bindings),
685        }
686    }
687    replacement
688}
689
690pub fn resolve_import_bindings(
691    python: &dyn PythonSource,
692    token: QueryToken<'_>,
693    file: &ProjectFile,
694) -> HashMap<String, CodeUnit> {
695    let imports = python.import_info_of(token, file);
696    let mut bindings = HashMap::default();
697    for resolved in resolve_imports_batched(python, file, &imports) {
698        for (binding, code_unit) in resolved {
699            bindings.insert(binding, code_unit);
700        }
701    }
702    bindings
703}
704
705/// Resolves every import in `imports` (`file`'s own imports), batching each import's primary
706/// module FQN lookup (see `primary_module_fqn`) into one store transaction instead of one per
707/// import. Shared by `resolve_import_bindings` and `resolve_import_target_files`, the two per-file
708/// "resolve everything" entry points -- both are called once per candidate file by the usages
709/// candidate walker, so unbatched resolution here means one store transaction per import times
710/// every file in the workspace.
711pub fn resolve_imports_batched(
712    python: &dyn PythonSource,
713    file: &ProjectFile,
714    imports: &[ImportInfo],
715) -> Vec<Vec<(String, CodeUnit)>> {
716    let primary_fqns: Vec<Option<String>> = imports
717        .iter()
718        .map(|import| primary_module_fqn(file, import))
719        .collect();
720    let to_resolve: Vec<String> = primary_fqns.iter().flatten().cloned().collect();
721    let mut batch_results =
722        resolve_module_code_units_batch(python, Some(file), &to_resolve).into_iter();
723
724    imports
725        .iter()
726        .zip(primary_fqns.iter())
727        .map(|(import, primary_fqn)| {
728            let hint = primary_fqn.as_ref().map(|_| batch_results.next().unwrap());
729            resolve_import_with_hint(python, file, import, hint.as_ref())
730        })
731        .collect()
732}
733
734/// Resolve Python imports to the workspace files whose modules they execute.
735///
736/// A file dependency graph does not need declaration binding: for
737/// `from pkg import member`, the dependency on `pkg` exists whether `member`
738/// is a local declaration, a re-export, or an optional external name. When
739/// `member` is itself the workspace submodule `pkg.member`, that file is a
740/// dependency too. Resolve both module spellings from the path-backed module
741/// index in one batch and deliberately avoid the exact-definition fallbacks
742/// used by [`resolve_imports_batched`].
743pub fn resolve_import_files_batched(
744    python: &dyn PythonSource,
745    file: &ProjectFile,
746    imports: &[ImportInfo],
747) -> HashSet<ProjectFile> {
748    let mut module_fqns = Vec::new();
749    for import in imports {
750        match python_import_details(import) {
751            Some(PythonImportDetails::Import { module, .. }) => module_fqns.push(module),
752            Some(PythonImportDetails::FromImport {
753                module,
754                name,
755                wildcard,
756                ..
757            }) => {
758                let resolved_module = if module.starts_with('.') {
759                    resolve_python_relative_module(file, &module)
760                } else {
761                    Some(module)
762                };
763                let Some(resolved_module) = resolved_module else {
764                    continue;
765                };
766                if !wildcard {
767                    module_fqns.push(format!("{resolved_module}.{name}"));
768                }
769                module_fqns.push(resolved_module);
770            }
771            None => {}
772        }
773    }
774    module_fqns.sort();
775    module_fqns.dedup();
776
777    python
778        .path_module_fqns_batch(&module_fqns)
779        .into_iter()
780        .flatten()
781        .flatten()
782        .filter(CodeUnit::is_module)
783        .map(|unit| unit.source().clone())
784        .collect()
785}
786
787/// The module FQN `resolve_import`'s fast path checks first, if any -- must stay in sync with the
788/// two `resolve_module_code_unit` call sites in `resolve_import_with_hint` below, since it's what
789/// lets `resolve_import_target_files` batch-resolve them ahead of the serial fallback logic.
790fn primary_module_fqn(file: &ProjectFile, import: &ImportInfo) -> Option<String> {
791    match python_import_details(import)? {
792        PythonImportDetails::Import { module, alias } => Some(python_namespace_binding_module(
793            import,
794            alias.as_deref(),
795            &module,
796        )),
797        PythonImportDetails::FromImport {
798            module,
799            name,
800            wildcard,
801            ..
802        } => {
803            if wildcard {
804                return None;
805            }
806            let resolved_module = if module.starts_with('.') {
807                resolve_python_relative_module(file, &module)
808            } else {
809                Some(module)
810            };
811            resolved_module.map(|resolved_module| format!("{resolved_module}.{name}"))
812        }
813    }
814}
815
816pub fn resolve_import(
817    python: &dyn PythonSource,
818    file: &ProjectFile,
819    import: &ImportInfo,
820) -> Vec<(String, CodeUnit)> {
821    resolve_import_with_hint(python, file, import, None)
822}
823
824/// `primary_hint`, when `Some`, is the already-resolved result of this import's primary module FQN
825/// (see `primary_module_fqn`) so the batched caller doesn't pay for a second lookup of the same FQN.
826fn resolve_import_with_hint(
827    python: &dyn PythonSource,
828    file: &ProjectFile,
829    import: &ImportInfo,
830    primary_hint: Option<&Option<CodeUnit>>,
831) -> Vec<(String, CodeUnit)> {
832    if let Some(details) = python_import_details(import) {
833        match details {
834            PythonImportDetails::Import { module, alias } => {
835                let binding = python_namespace_binding_name(import, alias.as_deref(), &module);
836                let bound_module =
837                    python_namespace_binding_module(import, alias.as_deref(), &module);
838                let resolved = match primary_hint {
839                    Some(hint) => hint.clone(),
840                    None => resolve_module_code_unit(python, Some(file), &bound_module),
841                };
842                if let Some(module_code_unit) = resolved {
843                    return vec![(binding, module_code_unit)];
844                }
845            }
846            PythonImportDetails::FromImport {
847                module,
848                name,
849                alias,
850                wildcard,
851            } => {
852                let resolved_module = if module.starts_with('.') {
853                    resolve_python_relative_module(file, &module)
854                } else {
855                    Some(module)
856                };
857                let Some(resolved_module) = resolved_module else {
858                    return Vec::new();
859                };
860                if wildcard {
861                    return public_declarations_in_module(python, file, &resolved_module)
862                        .into_iter()
863                        .map(|code_unit| (code_unit.identifier().to_string(), code_unit))
864                        .collect();
865                }
866
867                let binding = alias.clone().unwrap_or_else(|| name.clone());
868                let module_candidate = format!("{resolved_module}.{name}");
869                let resolved = match primary_hint {
870                    Some(hint) => hint.clone(),
871                    None => resolve_module_code_unit(python, Some(file), &module_candidate),
872                };
873                if let Some(code_unit) = resolved {
874                    return vec![(binding, code_unit)];
875                }
876                let exported =
877                    resolve_exported_name_from_module(python, Some(file), &resolved_module, &name);
878                if !exported.is_empty() {
879                    return exported
880                        .into_iter()
881                        .map(|code_unit| (binding.clone(), code_unit))
882                        .collect();
883                }
884                let definitions: Vec<_> = python.definitions(&module_candidate).collect();
885                if !definitions.is_empty() {
886                    return definitions
887                        .into_iter()
888                        .map(|code_unit| (binding.clone(), code_unit))
889                        .collect();
890                }
891                let package_candidate: Vec<_> = python
892                    .definitions(&format!("{resolved_module}.{name}"))
893                    .collect();
894                if !package_candidate.is_empty() {
895                    return package_candidate
896                        .into_iter()
897                        .map(|code_unit| (binding.clone(), code_unit))
898                        .collect();
899                }
900            }
901        }
902    }
903    Vec::new()
904}
905
906/// `importer`, when known, is the file whose import statement asked for `fqn`;
907/// its own import root is what decides between two same-named modules. `None`
908/// is for an FQN-level question no import statement asked.
909pub fn resolve_exported_fqn(
910    python: &dyn PythonSource,
911    importer: Option<&ProjectFile>,
912    fqn: &str,
913) -> Vec<CodeUnit> {
914    let Some((module, name)) = fqn.rsplit_once('.') else {
915        return Vec::new();
916    };
917    resolve_exported_name_from_module(python, importer, module, name)
918}
919
920/// Resolve an unambiguous chain of explicit named reexports without
921/// constructing export indexes for each intermediate module. Star exports,
922/// shadowing, and every other ambiguous shape return `None` so callers can
923/// use the complete, source-order-aware export resolver below.
924///
925/// Each hop carries its own importer: the first is the file that asked, and
926/// every later one is the module whose reexport named the next specifier, so
927/// a facade chain stays inside the roots that actually wrote it.
928fn resolve_direct_named_exported_fqn(
929    python: &dyn PythonSource,
930    importer: Option<&ProjectFile>,
931    fqn: &str,
932) -> Option<Vec<CodeUnit>> {
933    let (module, name) = fqn.rsplit_once('.')?;
934    let mut results = Vec::new();
935    let mut queue = VecDeque::from([(importer.cloned(), module.to_string(), name.to_string())]);
936    let mut visited = HashSet::default();
937
938    while let Some((importer, module, export_name)) = queue.pop_front() {
939        if !visited.insert((module.clone(), export_name.clone())) {
940            continue;
941        }
942        let module_unit = resolve_module_code_unit(python, importer.as_ref(), &module)?;
943        let file = module_unit.source();
944        let local = local_export_declarations(python, file, &export_name);
945        let binder = python.import_binder_of(file);
946        let binding = binder.bindings.get(&export_name);
947        if !local.is_empty() && binding.is_some() {
948            return None;
949        }
950        if !local.is_empty() {
951            results.extend(local);
952            continue;
953        }
954        let binding = binding?;
955        if binding.kind != ImportKind::Named {
956            return None;
957        }
958        let imported_name = binding.imported_name.as_ref()?;
959        queue.push_back((
960            Some(file.clone()),
961            binding.module_specifier.clone(),
962            imported_name.clone(),
963        ));
964    }
965
966    results.sort_by(|left, right| {
967        left.source()
968            .cmp(right.source())
969            .then_with(|| left.fq_name().cmp(&right.fq_name()))
970    });
971    results.dedup();
972    (!results.is_empty()).then_some(results)
973}
974
975/// Resolve a Python FQN with the cheapest semantically complete tier that
976/// can answer it. The direct reexport walk handles only proven,
977/// collision-free chains; ambiguous shapes use the ordered export index,
978/// and the exact lookup remains the final fallback for non-export symbols.
979///
980/// The first two tiers resolve a module specifier, so they take `importer`
981/// for the same reason the import resolver does. The exact lookup is a
982/// workspace-wide question about a dotted name and stays root-blind.
983pub fn resolve_fqn_candidates(
984    python: &dyn PythonSource,
985    importer: Option<&ProjectFile>,
986    fqn: &str,
987    exact: impl FnOnce(&str) -> Vec<CodeUnit>,
988) -> Vec<CodeUnit> {
989    if let Some(candidates) = resolve_direct_named_exported_fqn(python, importer, fqn) {
990        return candidates;
991    }
992    let candidates = resolve_exported_fqn(python, importer, fqn);
993    if !candidates.is_empty() {
994        return candidates;
995    }
996    exact(fqn)
997}
998
999fn resolve_exported_name_from_module(
1000    python: &dyn PythonSource,
1001    importer: Option<&ProjectFile>,
1002    module: &str,
1003    name: &str,
1004) -> Vec<CodeUnit> {
1005    let Some(module_unit) = resolve_module_code_unit(python, importer, module) else {
1006        return Vec::new();
1007    };
1008    resolve_exported_name(python, module_unit.source(), name)
1009}
1010
1011fn resolve_exported_name(
1012    python: &dyn PythonSource,
1013    module_file: &ProjectFile,
1014    name: &str,
1015) -> Vec<CodeUnit> {
1016    let mut results = Vec::new();
1017    let mut queue = VecDeque::from([(module_file.clone(), name.to_string())]);
1018    let mut visited = HashSet::default();
1019
1020    while let Some((file, export_name)) = queue.pop_front() {
1021        if !visited.insert((file.clone(), export_name.clone())) {
1022            continue;
1023        }
1024
1025        let index = python.export_index_of(&file);
1026        if let Some(entry) = index.exports_by_name.get(&export_name) {
1027            match entry {
1028                ExportEntry::Local { local_name } => {
1029                    results.extend(local_export_declarations(python, &file, local_name));
1030                }
1031                ExportEntry::ReexportedNamed {
1032                    module_specifier,
1033                    imported_name,
1034                } => {
1035                    for target_file in
1036                        resolve_module_files_for_export(python, &file, module_specifier)
1037                    {
1038                        queue.push_back((target_file, imported_name.clone()));
1039                    }
1040                }
1041                ExportEntry::ReexportedModule { module_specifier } => {
1042                    // Terminal: the export *is* the module, so the walk stops
1043                    // here instead of looking the name up inside it.
1044                    results.extend(resolve_module_code_unit(
1045                        python,
1046                        Some(&file),
1047                        module_specifier,
1048                    ));
1049                }
1050                ExportEntry::Default { local_name } => {
1051                    if let Some(local_name) = local_name {
1052                        results.extend(local_export_declarations(python, &file, local_name));
1053                    }
1054                }
1055            }
1056            continue;
1057        }
1058
1059        if !export_name.starts_with('_') {
1060            for star in &index.reexport_stars {
1061                for target_file in
1062                    resolve_module_files_for_export(python, &file, &star.module_specifier)
1063                {
1064                    queue.push_back((target_file, export_name.clone()));
1065                }
1066            }
1067        }
1068    }
1069
1070    results.sort_by(|left, right| {
1071        left.source()
1072            .cmp(right.source())
1073            .then_with(|| left.fq_name().cmp(&right.fq_name()))
1074    });
1075    results.dedup();
1076    results
1077}
1078
1079fn local_export_declarations(
1080    index: &dyn CodeUnitIndex,
1081    file: &ProjectFile,
1082    local_name: &str,
1083) -> Vec<CodeUnit> {
1084    index
1085        .top_level_declarations(file)
1086        .into_iter()
1087        .filter(|unit| {
1088            unit.identifier() == local_name
1089                && index
1090                    .parent_of(unit)
1091                    .is_some_and(|parent| parent.is_module() && parent.source() == file)
1092        })
1093        .collect()
1094}
1095
1096fn resolve_module_files_for_export(
1097    python: &dyn PythonSource,
1098    importing_file: &ProjectFile,
1099    module_specifier: &str,
1100) -> Vec<ProjectFile> {
1101    let resolved_module = if module_specifier.starts_with('.') {
1102        resolve_python_relative_module(importing_file, module_specifier)
1103    } else {
1104        Some(module_specifier.to_string())
1105    };
1106    let Some(resolved_module) = resolved_module else {
1107        return Vec::new();
1108    };
1109    // Tree-sitter tells us the import syntax, but module-to-file resolution
1110    // is analyzer state. Use the prebuilt module code-unit map here instead
1111    // of the usage index so interactive definition lookup stays lightweight.
1112    resolve_module_code_unit(python, Some(importing_file), &resolved_module)
1113        .map(|unit| vec![unit.source().clone()])
1114        .unwrap_or_default()
1115}
1116
1117pub fn extract_package_from_python_wildcard(import: &ImportInfo) -> Option<String> {
1118    let details = python_import_details(import)?;
1119    match details {
1120        PythonImportDetails::FromImport {
1121            module, wildcard, ..
1122        } if wildcard => Some(module),
1123        _ => None,
1124    }
1125}
1126
1127#[derive(Debug, Clone)]
1128pub enum PythonImportDetails {
1129    Import {
1130        module: String,
1131        alias: Option<String>,
1132    },
1133    FromImport {
1134        module: String,
1135        name: String,
1136        alias: Option<String>,
1137        wildcard: bool,
1138    },
1139}
1140
1141/// One import leaf interpreted directly from the Python AST.
1142///
1143/// The generic [`ImportInfo`] remains the display/query projection. The
1144/// source nodes are retained beside it so the coordinated primary producer can
1145/// intern declaration, binder, and alias occurrences without matching names or
1146/// byte ranges after the tree has been walked.
1147#[derive(Debug, Clone)]
1148pub struct PythonImportSyntax<'tree> {
1149    pub info: ImportInfo,
1150    pub declaration: Node<'tree>,
1151    pub target: Option<Node<'tree>>,
1152    pub alias: Option<Node<'tree>>,
1153}
1154
1155pub fn python_import_infos_from_node(node: Node<'_>, source: &str) -> Vec<ImportInfo> {
1156    python_import_syntaxes_from_node(node, source)
1157        .into_iter()
1158        .map(|syntax| syntax.info)
1159        .collect()
1160}
1161
1162/// Interpret every import leaf in one statement and retain its exact AST
1163/// binder nodes. Malformed leaves are omitted with the same admission rules as
1164/// the historical generic import projection.
1165pub fn python_import_syntaxes_from_node<'tree>(
1166    node: Node<'tree>,
1167    source: &str,
1168) -> Vec<PythonImportSyntax<'tree>> {
1169    match node.kind() {
1170        "import_statement" => python_namespace_import_syntaxes(node, source),
1171        "import_from_statement" => python_from_import_syntaxes(node, source),
1172        _ => Vec::new(),
1173    }
1174}
1175
1176pub fn python_import_details(import: &ImportInfo) -> Option<PythonImportDetails> {
1177    let path = import.path.as_ref()?;
1178    match path.kind? {
1179        StructuredImportPathKind::Namespace => Some(PythonImportDetails::Import {
1180            module: join_python_import_segments(&path.segments),
1181            alias: import.alias.clone(),
1182        }),
1183        // These declaration forms belong to Java and Rust, not Python.
1184        StructuredImportPathKind::StaticMember | StructuredImportPathKind::ExternCrate => None,
1185        StructuredImportPathKind::ImportFrom => {
1186            let (name, module_segments) = if import.is_wildcard {
1187                ("*".to_string(), path.segments.as_slice())
1188            } else {
1189                let (name, module_segments) = path.segments.split_last()?;
1190                (name.clone(), module_segments)
1191            };
1192            Some(PythonImportDetails::FromImport {
1193                module: join_python_import_segments(module_segments),
1194                name,
1195                alias: import.alias.clone(),
1196                wildcard: import.is_wildcard,
1197            })
1198        }
1199    }
1200}
1201
1202fn python_namespace_import_syntaxes<'tree>(
1203    node: Node<'tree>,
1204    source: &str,
1205) -> Vec<PythonImportSyntax<'tree>> {
1206    let mut infos = Vec::new();
1207    let mut cursor = node.walk();
1208    for imported in node.children_by_field_name("name", &mut cursor) {
1209        let (module_node, alias_node) = if imported.kind() == "aliased_import" {
1210            let Some(name) = imported.child_by_field_name("name") else {
1211                continue;
1212            };
1213            (name, imported.child_by_field_name("alias"))
1214        } else {
1215            (imported, None)
1216        };
1217        let alias = alias_node
1218            .map(|alias| py_node_text(alias, source).trim().to_string())
1219            .filter(|alias| !alias.is_empty());
1220        let segments = python_path_segments(module_node, source);
1221        if segments.is_empty() {
1222            continue;
1223        }
1224        // `import a.b` binds `a`: the first segment's own token. A renamed
1225        // import binds its alias token instead.
1226        let target = alias
1227            .is_none()
1228            .then(|| python_first_segment_node(module_node))
1229            .flatten();
1230        let binder_span = alias_node
1231            .or(target)
1232            .map(brokk_bifrost_core::analyzer::common::node_span);
1233        let module = join_python_import_segments(&segments);
1234        let identifier = alias.clone().or_else(|| segments.first().cloned());
1235        let info = ImportInfo {
1236            raw_snippet: if let Some(alias) = &alias {
1237                format!("import {module} as {alias}")
1238            } else {
1239                format!("import {module}")
1240            },
1241            is_wildcard: false,
1242            is_global: false,
1243            identifier,
1244            alias,
1245            path: Some(StructuredImportPath {
1246                segments,
1247                kind: Some(StructuredImportPathKind::Namespace),
1248                lexical_prefixes: Vec::new(),
1249                lexical_scopes: Vec::new(),
1250                declaration_start_byte: node.start_byte(),
1251            }),
1252            binder_span,
1253        };
1254        infos.push(PythonImportSyntax {
1255            info,
1256            declaration: node,
1257            target,
1258            alias: alias_node,
1259        });
1260    }
1261    infos
1262}
1263
1264fn python_from_import_syntaxes<'tree>(
1265    node: Node<'tree>,
1266    source: &str,
1267) -> Vec<PythonImportSyntax<'tree>> {
1268    let Some(module_node) = node.child_by_field_name("module_name") else {
1269        return Vec::new();
1270    };
1271    let module_segments = python_module_segments(module_node, source);
1272    if module_segments.is_empty() {
1273        return Vec::new();
1274    }
1275
1276    let mut infos = Vec::new();
1277    let has_wildcard_import = {
1278        let mut cursor = node.walk();
1279        node.named_children(&mut cursor)
1280            .any(|child| child.kind() == "wildcard_import")
1281    };
1282    let mut cursor = node.walk();
1283    let imported_names: Vec<_> = node.children_by_field_name("name", &mut cursor).collect();
1284    if has_wildcard_import {
1285        let module = join_python_import_segments(&module_segments);
1286        let info = ImportInfo {
1287            raw_snippet: format!("from {module} import *"),
1288            is_wildcard: true,
1289            is_global: false,
1290            identifier: None,
1291            alias: None,
1292            path: Some(StructuredImportPath {
1293                segments: module_segments,
1294                kind: Some(StructuredImportPathKind::ImportFrom),
1295                lexical_prefixes: Vec::new(),
1296                lexical_scopes: Vec::new(),
1297                declaration_start_byte: node.start_byte(),
1298            }),
1299            binder_span: None,
1300        };
1301        infos.push(PythonImportSyntax {
1302            info,
1303            declaration: node,
1304            target: None,
1305            alias: None,
1306        });
1307        return infos;
1308    }
1309    if imported_names.is_empty() {
1310        return infos;
1311    }
1312
1313    for imported in imported_names {
1314        let (name_node, alias_node) = if imported.kind() == "aliased_import" {
1315            let Some(name) = imported.child_by_field_name("name") else {
1316                continue;
1317            };
1318            (name, imported.child_by_field_name("alias"))
1319        } else {
1320            (imported, None)
1321        };
1322        let alias = alias_node
1323            .map(|alias| py_node_text(alias, source).trim().to_string())
1324            .filter(|alias| !alias.is_empty());
1325        let name_segments = python_path_segments(name_node, source);
1326        if name_segments.is_empty() {
1327            continue;
1328        }
1329        // `from m import x` binds `x`'s own token; a rename binds the alias
1330        // token. A multi-segment imported name binds no single token.
1331        let target = alias
1332            .is_none()
1333            .then(|| {
1334                (name_segments.len() == 1)
1335                    .then(|| python_first_segment_node(name_node))
1336                    .flatten()
1337            })
1338            .flatten();
1339        let binder_span = alias_node
1340            .or(target)
1341            .map(brokk_bifrost_core::analyzer::common::node_span);
1342        let imported_name = join_python_import_segments(&name_segments);
1343        let mut segments = module_segments.clone();
1344        segments.extend(name_segments);
1345        let module = join_python_import_segments(&module_segments);
1346        let info = ImportInfo {
1347            raw_snippet: if let Some(alias) = &alias {
1348                format!("from {module} import {imported_name} as {alias}")
1349            } else {
1350                format!("from {module} import {imported_name}")
1351            },
1352            is_wildcard: false,
1353            is_global: false,
1354            identifier: Some(alias.clone().unwrap_or_else(|| imported_name.clone())),
1355            alias,
1356            path: Some(StructuredImportPath {
1357                segments,
1358                kind: Some(StructuredImportPathKind::ImportFrom),
1359                lexical_prefixes: Vec::new(),
1360                lexical_scopes: Vec::new(),
1361                declaration_start_byte: node.start_byte(),
1362            }),
1363            binder_span,
1364        };
1365        infos.push(PythonImportSyntax {
1366            info,
1367            declaration: node,
1368            target,
1369            alias: alias_node,
1370        });
1371    }
1372    infos
1373}
1374
1375fn python_module_segments(module: Node<'_>, source: &str) -> Vec<String> {
1376    if module.kind() == "relative_import" {
1377        let mut cursor = module.walk();
1378        let mut prefix = String::new();
1379        let mut path_node = None;
1380        for child in module.named_children(&mut cursor) {
1381            match child.kind() {
1382                "import_prefix" if prefix.is_empty() => {
1383                    prefix = py_node_text(child, source).trim().to_string();
1384                }
1385                "dotted_name" if path_node.is_none() => {
1386                    path_node = Some(child);
1387                }
1388                _ => {}
1389            }
1390        }
1391        let mut segments = path_node
1392            .map(|path| python_path_segments(path, source))
1393            .unwrap_or_default();
1394        if !prefix.is_empty() {
1395            if let Some(first) = segments.first_mut() {
1396                first.insert_str(0, &prefix);
1397            } else {
1398                segments.push(prefix);
1399            }
1400        }
1401        return segments;
1402    }
1403    python_path_segments(module, source)
1404}
1405
1406/// The token that spells a path's first segment: the identifier itself, or a
1407/// dotted name's first identifier. `None` when the shape has no leading
1408/// identifier token of its own (e.g. a relative-import prefix).
1409fn python_first_segment_node(node: Node<'_>) -> Option<Node<'_>> {
1410    match node.kind() {
1411        "identifier" => Some(node),
1412        "dotted_name" => {
1413            let mut cursor = node.walk();
1414            node.named_children(&mut cursor)
1415                .find(|child| child.kind() == "identifier")
1416        }
1417        _ => None,
1418    }
1419}
1420
1421fn python_path_segments(node: Node<'_>, source: &str) -> Vec<String> {
1422    match node.kind() {
1423        "identifier" => vec![py_node_text(node, source).trim().to_string()],
1424        "dotted_name" => {
1425            let mut segments = Vec::new();
1426            let mut cursor = node.walk();
1427            for child in node.named_children(&mut cursor) {
1428                segments.extend(python_path_segments(child, source));
1429            }
1430            segments
1431        }
1432        _ => {
1433            let mut segments = Vec::new();
1434            let mut cursor = node.walk();
1435            for child in node.named_children(&mut cursor) {
1436                segments.extend(python_path_segments(child, source));
1437            }
1438            segments
1439        }
1440    }
1441}
1442
1443fn join_python_import_segments(segments: &[String]) -> String {
1444    let Some((first, rest)) = segments.split_first() else {
1445        return String::new();
1446    };
1447    if first.starts_with('.') && !rest.is_empty() {
1448        format!("{first}.{}", rest.join("."))
1449    } else {
1450        segments.join(".")
1451    }
1452}
1453
1454pub fn python_namespace_binding_name(
1455    import: &ImportInfo,
1456    alias: Option<&str>,
1457    module: &str,
1458) -> String {
1459    import
1460        .identifier
1461        .clone()
1462        .or_else(|| alias.map(str::to_string))
1463        .unwrap_or_else(|| module.to_string())
1464}
1465
1466pub fn python_namespace_binding_module(
1467    import: &ImportInfo,
1468    alias: Option<&str>,
1469    module: &str,
1470) -> String {
1471    if alias.is_some() {
1472        return module.to_string();
1473    }
1474    import
1475        .path
1476        .as_ref()
1477        .and_then(|path| path.segments.first().cloned())
1478        .unwrap_or_else(|| module.to_string())
1479}
1480
1481pub fn resolve_python_relative_module(
1482    source_file: &ProjectFile,
1483    module_expr: &str,
1484) -> Option<String> {
1485    resolve_python_relative_module_from_package(&python_current_package(source_file), module_expr)
1486}
1487
1488/// Resolve a structured Python module expression against an already-known
1489/// package identity. Dependency-pack producers know module identities without
1490/// owning a workspace [`ProjectFile`], so they use this entry point.
1491pub fn resolve_python_relative_module_from_package(
1492    current_package: &str,
1493    module_expr: &str,
1494) -> Option<String> {
1495    let level = module_expr.chars().take_while(|ch| *ch == '.').count();
1496    let suffix = module_expr[level..].trim_matches('.');
1497    let mut parts: Vec<_> = current_package
1498        .split('.')
1499        .filter(|part| !part.is_empty())
1500        .map(str::to_string)
1501        .collect();
1502    if level == 0 {
1503        return Some(module_expr.to_string());
1504    }
1505    if level > 0 {
1506        if level - 1 > parts.len() {
1507            return None;
1508        }
1509        parts.truncate(parts.len() - (level - 1));
1510    }
1511    if !suffix.is_empty() {
1512        parts.extend(suffix.split('.').map(str::to_string));
1513    }
1514    Some(parts.join("."))
1515}
1516
1517fn python_current_package(source_file: &ProjectFile) -> String {
1518    let module = python_module_name(source_file);
1519    if source_file
1520        .rel_path()
1521        .file_name()
1522        .and_then(|name| name.to_str())
1523        == Some("__init__.py")
1524    {
1525        module
1526    } else {
1527        module
1528            .rsplit_once('.')
1529            .map(|(package, _)| package.to_string())
1530            .unwrap_or_default()
1531    }
1532}