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::usages::model::{ExportEntry, ImportBinding, ImportKind};
18use brokk_bifrost_core::analyzer::{CodeUnit, CodeUnitIndex, ProjectFile};
19use brokk_bifrost_core::hash::{HashMap, HashSet};
20use std::collections::VecDeque;
21use tree_sitter::Node;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct PythonModuleReplacement {
25    pub target_module: String,
26}
27
28/// Recognize the compatibility-shim idiom that replaces the current module
29/// object with an imported workspace module:
30///
31/// `sys.modules[__name__] = imported_module`
32///
33/// Every component is resolved from tree-sitter fields and the import binder;
34/// similarly spelled attributes or locals do not create an alias edge.
35fn module_replacement_from_assignment(
36    assignment: Node<'_>,
37    source: &str,
38    bindings: &HashMap<String, ImportBinding>,
39) -> Option<PythonModuleReplacement> {
40    let (left, right) = (
41        assignment.child_by_field_name("left")?,
42        assignment.child_by_field_name("right")?,
43    );
44    if left.kind() != "subscript" || right.kind() != "identifier" {
45        return None;
46    }
47    let (value, subscript) = (
48        left.child_by_field_name("value")?,
49        left.child_by_field_name("subscript")?,
50    );
51    if value.kind() != "attribute"
52        || subscript.kind() != "identifier"
53        || node_source_text(subscript, source) != "__name__"
54    {
55        return None;
56    }
57    let (sys_local, modules) = (
58        value.child_by_field_name("object")?,
59        value.child_by_field_name("attribute")?,
60    );
61    if sys_local.kind() != "identifier"
62        || modules.kind() != "identifier"
63        || node_source_text(modules, source) != "modules"
64    {
65        return None;
66    }
67    let sys_binding = bindings.get(node_source_text(sys_local, source))?;
68    let sys_module = sys_binding
69        .namespace_imported_module
70        .as_deref()
71        .unwrap_or(&sys_binding.module_specifier);
72    if sys_binding.kind != ImportKind::Namespace || sys_module != "sys" {
73        return None;
74    }
75
76    let target_binding = bindings.get(node_source_text(right, source))?;
77    if target_binding.kind != ImportKind::Namespace {
78        return None;
79    }
80    Some(PythonModuleReplacement {
81        target_module: target_binding
82            .namespace_imported_module
83            .clone()
84            .unwrap_or_else(|| target_binding.module_specifier.clone()),
85    })
86}
87
88fn remove_direct_scope_bindings(
89    statement: Node<'_>,
90    source: &str,
91    bindings: &mut HashMap<String, ImportBinding>,
92) {
93    let mut stack = vec![statement];
94    while let Some(node) = stack.pop() {
95        for binding in python_direct_scope_bindings_bounded(node, source, || true)
96            .expect("unbounded binding collection cannot be cancelled")
97        {
98            bindings.remove(node_source_text(binding.declaration, source));
99        }
100        if matches!(
101            node.kind(),
102            "function_definition" | "class_definition" | "lambda"
103        ) {
104            continue;
105        }
106        let mut cursor = node.walk();
107        stack.extend(node.named_children(&mut cursor));
108    }
109}
110
111/// Parse the structured import facts for a Python document without executing
112/// it. LSP model binding uses the same AST-derived representation as the
113/// analyzer so external APIs are never selected by a terminal-name scan.
114pub fn parse_python_import_infos(source: &str) -> Vec<ImportInfo> {
115    let mut parser = tree_sitter::Parser::new();
116    parser
117        .set_language(&tree_sitter_python::LANGUAGE.into())
118        .expect("failed to load Python parser");
119    let Some(tree) = parser.parse(source, None) else {
120        return Vec::new();
121    };
122    let mut pending = vec![tree.root_node()];
123    let mut imports = Vec::new();
124    while let Some(node) = pending.pop() {
125        if matches!(node.kind(), "import_statement" | "import_from_statement") {
126            imports.extend(python_import_infos_from_node(node, source));
127            continue;
128        }
129        let mut cursor = node.walk();
130        pending.extend(node.named_children(&mut cursor));
131    }
132    imports
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct PythonImportBinding {
137    pub start_byte: usize,
138    pub scope_start_byte: usize,
139    pub scope_end_byte: usize,
140    function_scoped: bool,
141    pub local_name: String,
142    pub qualified_name: String,
143    pub consumed_attributes: usize,
144}
145
146impl PythonImportBinding {
147    pub fn is_function_scoped(&self) -> bool {
148        self.function_scoped
149    }
150}
151
152/// Return source-ordered, parser-derived local bindings for explicit Python
153/// imports. The byte offset lets callers select the last visible binding rather
154/// than treating a whole document as one unordered import scope.
155pub fn parse_python_import_bindings(source: &str) -> Vec<PythonImportBinding> {
156    let mut parser = tree_sitter::Parser::new();
157    parser
158        .set_language(&tree_sitter_python::LANGUAGE.into())
159        .expect("failed to load Python parser");
160    let Some(tree) = parser.parse(source, None) else {
161        return Vec::new();
162    };
163    let mut pending = vec![tree.root_node()];
164    let mut nodes = Vec::new();
165    while let Some(node) = pending.pop() {
166        if matches!(node.kind(), "import_statement" | "import_from_statement") {
167            nodes.push(node);
168            continue;
169        }
170        let mut cursor = node.walk();
171        pending.extend(node.named_children(&mut cursor));
172    }
173    nodes.sort_by_key(Node::start_byte);
174    nodes
175        .into_iter()
176        .flat_map(|node| {
177            let (scope_start_byte, scope_end_byte, function_scoped) =
178                python_import_binding_scope(node, source.len());
179            python_import_infos_from_node(node, source)
180                .into_iter()
181                .filter_map(move |import| {
182                    let path = import.path.as_ref()?;
183                    let details = python_import_details(&import)?;
184                    match details {
185                        PythonImportDetails::Import { module, alias } => {
186                            let consumed_attributes = if alias.is_some() {
187                                0
188                            } else {
189                                path.segments.len().saturating_sub(1)
190                            };
191                            Some(PythonImportBinding {
192                                start_byte: node.start_byte(),
193                                scope_start_byte,
194                                scope_end_byte,
195                                function_scoped,
196                                local_name: alias.or_else(|| path.segments.first().cloned())?,
197                                qualified_name: module,
198                                consumed_attributes,
199                            })
200                        }
201                        PythonImportDetails::FromImport {
202                            module,
203                            name,
204                            alias,
205                            wildcard: false,
206                        } => Some(PythonImportBinding {
207                            start_byte: node.start_byte(),
208                            scope_start_byte,
209                            scope_end_byte,
210                            function_scoped,
211                            local_name: alias.unwrap_or(name.clone()),
212                            qualified_name: format!("{module}.{name}"),
213                            consumed_attributes: 0,
214                        }),
215                        PythonImportDetails::FromImport { wildcard: true, .. } => None,
216                    }
217                })
218        })
219        .collect()
220}
221
222fn python_import_binding_scope(node: Node<'_>, source_len: usize) -> (usize, usize, bool) {
223    let mut parent = node.parent();
224    while let Some(scope) = parent {
225        if matches!(scope.kind(), "function_definition" | "lambda") {
226            return (scope.start_byte(), scope.end_byte(), true);
227        }
228        parent = scope.parent();
229    }
230    (0, source_len, false)
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use brokk_bifrost_core::analyzer::usages::model::ImportBinder;
237
238    fn replacement_for(source: &str, binder: &ImportBinder) -> Option<PythonModuleReplacement> {
239        let tree = parse_python_tree(source).expect("valid Python fixture");
240        let root = tree.root_node();
241        let mut replacement = None;
242        let mut cursor = root.walk();
243        for statement in root.named_children(&mut cursor) {
244            let statement = if statement.kind() == "expression_statement" {
245                statement.named_child(0).expect("fixture expression")
246            } else {
247                statement
248            };
249            if statement.kind() == "assignment"
250                && let Some(next) =
251                    module_replacement_from_assignment(statement, source, &binder.bindings)
252                && replacement.replace(next).is_some()
253            {
254                return None;
255            }
256        }
257        replacement
258    }
259
260    #[test]
261    fn module_replacement_requires_exact_structured_import_bindings() {
262        let source = r#"import sys as _sys
263from routes.contacts import contacts_routes as _canonical
264
265_sys.modules[__name__] = _canonical
266"#;
267        let mut binder = ImportBinder::empty();
268        binder.bindings.insert(
269            "_sys".to_string(),
270            ImportBinding {
271                module_specifier: "sys".to_string(),
272                namespace_imported_module: Some("sys".to_string()),
273                kind: ImportKind::Namespace,
274                imported_name: None,
275            },
276        );
277        binder.bindings.insert(
278            "_canonical".to_string(),
279            ImportBinding {
280                module_specifier: "routes.contacts.contacts_routes".to_string(),
281                namespace_imported_module: None,
282                kind: ImportKind::Namespace,
283                imported_name: None,
284            },
285        );
286
287        assert_eq!(
288            replacement_for(source, &binder),
289            Some(PythonModuleReplacement {
290                target_module: "routes.contacts.contacts_routes".to_string(),
291            })
292        );
293
294        for near_miss in [
295            "cache.modules[__name__] = _canonical\n",
296            "_sys.modules[module_name] = _canonical\n",
297            "_sys.modules[__name__] = build()\n",
298            "def replace():\n    _sys.modules[__name__] = _canonical\n",
299        ] {
300            assert_eq!(
301                replacement_for(near_miss, &binder),
302                None,
303                "near miss must not replace module identity: {near_miss:?}"
304            );
305        }
306    }
307}
308
309pub fn module_replacement_of(
310    python: &dyn PythonSource,
311    file: &ProjectFile,
312    source: &str,
313) -> Option<PythonModuleReplacement> {
314    let tree = parse_python_tree(source)?;
315    let root = tree.root_node();
316    let mut bindings: HashMap<String, ImportBinding> = HashMap::default();
317    let mut replacement = None;
318    let mut cursor = root.walk();
319    for statement in root.named_children(&mut cursor) {
320        let statement = if statement.kind() == "expression_statement" {
321            let Some(expression) = statement.named_child(0) else {
322                continue;
323            };
324            expression
325        } else {
326            statement
327        };
328        match statement.kind() {
329            "import_statement" | "import_from_statement" => {
330                let imports = python_import_infos_from_node(statement, source);
331                bindings.extend(import_binder_from_imports(python, file, &imports).bindings);
332            }
333            "assignment" => {
334                if let Some(next) = module_replacement_from_assignment(statement, source, &bindings)
335                    && replacement.replace(next).is_some()
336                {
337                    return None;
338                }
339                remove_direct_scope_bindings(statement, source, &mut bindings);
340            }
341            _ => remove_direct_scope_bindings(statement, source, &mut bindings),
342        }
343    }
344    replacement
345}
346
347pub fn resolve_import_bindings(
348    python: &dyn PythonSource,
349    file: &ProjectFile,
350) -> HashMap<String, CodeUnit> {
351    let imports = python.import_info_of(file);
352    let mut bindings = HashMap::default();
353    for resolved in resolve_imports_batched(python, file, &imports) {
354        for (binding, code_unit) in resolved {
355            bindings.insert(binding, code_unit);
356        }
357    }
358    bindings
359}
360
361/// Resolves every import in `imports` (`file`'s own imports), batching each import's primary
362/// module FQN lookup (see `primary_module_fqn`) into one store transaction instead of one per
363/// import. Shared by `resolve_import_bindings` and `resolve_import_target_files`, the two per-file
364/// "resolve everything" entry points -- both are called once per candidate file by the usages
365/// candidate walker, so unbatched resolution here means one store transaction per import times
366/// every file in the workspace.
367pub fn resolve_imports_batched(
368    python: &dyn PythonSource,
369    file: &ProjectFile,
370    imports: &[ImportInfo],
371) -> Vec<Vec<(String, CodeUnit)>> {
372    let primary_fqns: Vec<Option<String>> = imports
373        .iter()
374        .map(|import| primary_module_fqn(file, import))
375        .collect();
376    let to_resolve: Vec<String> = primary_fqns.iter().flatten().cloned().collect();
377    let mut batch_results = resolve_module_code_units_batch(python, &to_resolve).into_iter();
378
379    imports
380        .iter()
381        .zip(primary_fqns.iter())
382        .map(|(import, primary_fqn)| {
383            let hint = primary_fqn.as_ref().map(|_| batch_results.next().unwrap());
384            resolve_import_with_hint(python, file, import, hint.as_ref())
385        })
386        .collect()
387}
388
389/// The module FQN `resolve_import`'s fast path checks first, if any -- must stay in sync with the
390/// two `resolve_module_code_unit` call sites in `resolve_import_with_hint` below, since it's what
391/// lets `resolve_import_target_files` batch-resolve them ahead of the serial fallback logic.
392fn primary_module_fqn(file: &ProjectFile, import: &ImportInfo) -> Option<String> {
393    match python_import_details(import)? {
394        PythonImportDetails::Import { module, alias } => Some(python_namespace_binding_module(
395            import,
396            alias.as_deref(),
397            &module,
398        )),
399        PythonImportDetails::FromImport {
400            module,
401            name,
402            wildcard,
403            ..
404        } => {
405            if wildcard {
406                return None;
407            }
408            let resolved_module = if module.starts_with('.') {
409                resolve_python_relative_module(file, &module)
410            } else {
411                Some(module)
412            };
413            resolved_module.map(|resolved_module| format!("{resolved_module}.{name}"))
414        }
415    }
416}
417
418pub fn resolve_import(
419    python: &dyn PythonSource,
420    file: &ProjectFile,
421    import: &ImportInfo,
422) -> Vec<(String, CodeUnit)> {
423    resolve_import_with_hint(python, file, import, None)
424}
425
426/// `primary_hint`, when `Some`, is the already-resolved result of this import's primary module FQN
427/// (see `primary_module_fqn`) so the batched caller doesn't pay for a second lookup of the same FQN.
428fn resolve_import_with_hint(
429    python: &dyn PythonSource,
430    file: &ProjectFile,
431    import: &ImportInfo,
432    primary_hint: Option<&Option<CodeUnit>>,
433) -> Vec<(String, CodeUnit)> {
434    if let Some(details) = python_import_details(import) {
435        match details {
436            PythonImportDetails::Import { module, alias } => {
437                let binding = python_namespace_binding_name(import, alias.as_deref(), &module);
438                let bound_module =
439                    python_namespace_binding_module(import, alias.as_deref(), &module);
440                let resolved = match primary_hint {
441                    Some(hint) => hint.clone(),
442                    None => resolve_module_code_unit(python, &bound_module),
443                };
444                if let Some(module_code_unit) = resolved {
445                    return vec![(binding, module_code_unit)];
446                }
447            }
448            PythonImportDetails::FromImport {
449                module,
450                name,
451                alias,
452                wildcard,
453            } => {
454                let resolved_module = if module.starts_with('.') {
455                    resolve_python_relative_module(file, &module)
456                } else {
457                    Some(module)
458                };
459                let Some(resolved_module) = resolved_module else {
460                    return Vec::new();
461                };
462                if wildcard {
463                    return public_declarations_in_module(python, &resolved_module)
464                        .into_iter()
465                        .map(|code_unit| (code_unit.identifier().to_string(), code_unit))
466                        .collect();
467                }
468
469                let binding = alias.clone().unwrap_or_else(|| name.clone());
470                let module_candidate = format!("{resolved_module}.{name}");
471                let resolved = match primary_hint {
472                    Some(hint) => hint.clone(),
473                    None => resolve_module_code_unit(python, &module_candidate),
474                };
475                if let Some(code_unit) = resolved {
476                    return vec![(binding, code_unit)];
477                }
478                let exported = resolve_exported_name_from_module(python, &resolved_module, &name);
479                if !exported.is_empty() {
480                    return exported
481                        .into_iter()
482                        .map(|code_unit| (binding.clone(), code_unit))
483                        .collect();
484                }
485                let definitions: Vec<_> = python.definitions(&module_candidate).collect();
486                if !definitions.is_empty() {
487                    return definitions
488                        .into_iter()
489                        .map(|code_unit| (binding.clone(), code_unit))
490                        .collect();
491                }
492                let package_candidate: Vec<_> = python
493                    .definitions(&format!("{resolved_module}.{name}"))
494                    .collect();
495                if !package_candidate.is_empty() {
496                    return package_candidate
497                        .into_iter()
498                        .map(|code_unit| (binding.clone(), code_unit))
499                        .collect();
500                }
501            }
502        }
503    }
504    Vec::new()
505}
506
507pub fn resolve_exported_fqn(python: &dyn PythonSource, fqn: &str) -> Vec<CodeUnit> {
508    let Some((module, name)) = fqn.rsplit_once('.') else {
509        return Vec::new();
510    };
511    resolve_exported_name_from_module(python, module, name)
512}
513
514/// Resolve an unambiguous chain of explicit named reexports without
515/// constructing export indexes for each intermediate module. Star exports,
516/// shadowing, and every other ambiguous shape return `None` so callers can
517/// use the complete, source-order-aware export resolver below.
518fn resolve_direct_named_exported_fqn(
519    python: &dyn PythonSource,
520    fqn: &str,
521) -> Option<Vec<CodeUnit>> {
522    let (module, name) = fqn.rsplit_once('.')?;
523    let mut results = Vec::new();
524    let mut queue = VecDeque::from([(module.to_string(), name.to_string())]);
525    let mut visited = HashSet::default();
526
527    while let Some((module, export_name)) = queue.pop_front() {
528        if !visited.insert((module.clone(), export_name.clone())) {
529            continue;
530        }
531        let module_unit = resolve_module_code_unit(python, &module)?;
532        let file = module_unit.source();
533        let local = local_export_declarations(python, file, &export_name);
534        let binder = python.import_binder_of(file);
535        let binding = binder.bindings.get(&export_name);
536        if !local.is_empty() && binding.is_some() {
537            return None;
538        }
539        if !local.is_empty() {
540            results.extend(local);
541            continue;
542        }
543        let binding = binding?;
544        if binding.kind != ImportKind::Named {
545            return None;
546        }
547        let imported_name = binding.imported_name.as_ref()?;
548        queue.push_back((binding.module_specifier.clone(), imported_name.clone()));
549    }
550
551    results.sort_by(|left, right| {
552        left.source()
553            .cmp(right.source())
554            .then_with(|| left.fq_name().cmp(&right.fq_name()))
555    });
556    results.dedup();
557    (!results.is_empty()).then_some(results)
558}
559
560/// Resolve a Python FQN with the cheapest semantically complete tier that
561/// can answer it. The direct reexport walk handles only proven,
562/// collision-free chains; ambiguous shapes use the ordered export index,
563/// and the exact lookup remains the final fallback for non-export symbols.
564pub fn resolve_fqn_candidates(
565    python: &dyn PythonSource,
566    fqn: &str,
567    exact: impl FnOnce(&str) -> Vec<CodeUnit>,
568) -> Vec<CodeUnit> {
569    if let Some(candidates) = resolve_direct_named_exported_fqn(python, fqn) {
570        return candidates;
571    }
572    let candidates = resolve_exported_fqn(python, fqn);
573    if !candidates.is_empty() {
574        return candidates;
575    }
576    exact(fqn)
577}
578
579fn resolve_exported_name_from_module(
580    python: &dyn PythonSource,
581    module: &str,
582    name: &str,
583) -> Vec<CodeUnit> {
584    let Some(module_unit) = resolve_module_code_unit(python, module) else {
585        return Vec::new();
586    };
587    resolve_exported_name(python, module_unit.source(), name)
588}
589
590fn resolve_exported_name(
591    python: &dyn PythonSource,
592    module_file: &ProjectFile,
593    name: &str,
594) -> Vec<CodeUnit> {
595    let mut results = Vec::new();
596    let mut queue = VecDeque::from([(module_file.clone(), name.to_string())]);
597    let mut visited = HashSet::default();
598
599    while let Some((file, export_name)) = queue.pop_front() {
600        if !visited.insert((file.clone(), export_name.clone())) {
601            continue;
602        }
603
604        let index = python.export_index_of(&file);
605        if let Some(entry) = index.exports_by_name.get(&export_name) {
606            match entry {
607                ExportEntry::Local { local_name } => {
608                    results.extend(local_export_declarations(python, &file, local_name));
609                }
610                ExportEntry::ReexportedNamed {
611                    module_specifier,
612                    imported_name,
613                } => {
614                    for target_file in
615                        resolve_module_files_for_export(python, &file, module_specifier)
616                    {
617                        queue.push_back((target_file, imported_name.clone()));
618                    }
619                }
620                ExportEntry::ReexportedModule { module_specifier } => {
621                    // Terminal: the export *is* the module, so the walk stops
622                    // here instead of looking the name up inside it.
623                    results.extend(resolve_module_code_unit(python, module_specifier));
624                }
625                ExportEntry::Default { local_name } => {
626                    if let Some(local_name) = local_name {
627                        results.extend(local_export_declarations(python, &file, local_name));
628                    }
629                }
630            }
631            continue;
632        }
633
634        if !export_name.starts_with('_') {
635            for star in &index.reexport_stars {
636                for target_file in
637                    resolve_module_files_for_export(python, &file, &star.module_specifier)
638                {
639                    queue.push_back((target_file, export_name.clone()));
640                }
641            }
642        }
643    }
644
645    results.sort_by(|left, right| {
646        left.source()
647            .cmp(right.source())
648            .then_with(|| left.fq_name().cmp(&right.fq_name()))
649    });
650    results.dedup();
651    results
652}
653
654fn local_export_declarations(
655    index: &dyn CodeUnitIndex,
656    file: &ProjectFile,
657    local_name: &str,
658) -> Vec<CodeUnit> {
659    index
660        .top_level_declarations(file)
661        .into_iter()
662        .filter(|unit| {
663            unit.identifier() == local_name
664                && index
665                    .parent_of(unit)
666                    .is_some_and(|parent| parent.is_module() && parent.source() == file)
667        })
668        .collect()
669}
670
671fn resolve_module_files_for_export(
672    python: &dyn PythonSource,
673    importing_file: &ProjectFile,
674    module_specifier: &str,
675) -> Vec<ProjectFile> {
676    let resolved_module = if module_specifier.starts_with('.') {
677        resolve_python_relative_module(importing_file, module_specifier)
678    } else {
679        Some(module_specifier.to_string())
680    };
681    let Some(resolved_module) = resolved_module else {
682        return Vec::new();
683    };
684    // Tree-sitter tells us the import syntax, but module-to-file resolution
685    // is analyzer state. Use the prebuilt module code-unit map here instead
686    // of the usage index so interactive definition lookup stays lightweight.
687    resolve_module_code_unit(python, &resolved_module)
688        .map(|unit| vec![unit.source().clone()])
689        .unwrap_or_default()
690}
691
692pub fn extract_package_from_python_wildcard(import: &ImportInfo) -> Option<String> {
693    let details = python_import_details(import)?;
694    match details {
695        PythonImportDetails::FromImport {
696            module, wildcard, ..
697        } if wildcard => Some(module),
698        _ => None,
699    }
700}
701
702#[derive(Debug, Clone)]
703pub enum PythonImportDetails {
704    Import {
705        module: String,
706        alias: Option<String>,
707    },
708    FromImport {
709        module: String,
710        name: String,
711        alias: Option<String>,
712        wildcard: bool,
713    },
714}
715
716pub fn python_import_infos_from_node(node: Node<'_>, source: &str) -> Vec<ImportInfo> {
717    match node.kind() {
718        "import_statement" => python_namespace_import_infos(node, source),
719        "import_from_statement" => python_from_import_infos(node, source),
720        _ => Vec::new(),
721    }
722}
723
724pub fn python_import_details(import: &ImportInfo) -> Option<PythonImportDetails> {
725    let path = import.path.as_ref()?;
726    match path.kind? {
727        StructuredImportPathKind::Namespace => Some(PythonImportDetails::Import {
728            module: join_python_import_segments(&path.segments),
729            alias: import.alias.clone(),
730        }),
731        // Python has no static imports; the variant belongs to Java.
732        StructuredImportPathKind::StaticMember => None,
733        StructuredImportPathKind::ImportFrom => {
734            let (name, module_segments) = if import.is_wildcard {
735                ("*".to_string(), path.segments.as_slice())
736            } else {
737                let (name, module_segments) = path.segments.split_last()?;
738                (name.clone(), module_segments)
739            };
740            Some(PythonImportDetails::FromImport {
741                module: join_python_import_segments(module_segments),
742                name,
743                alias: import.alias.clone(),
744                wildcard: import.is_wildcard,
745            })
746        }
747    }
748}
749
750fn python_namespace_import_infos(node: Node<'_>, source: &str) -> Vec<ImportInfo> {
751    let mut infos = Vec::new();
752    let mut cursor = node.walk();
753    for imported in node.children_by_field_name("name", &mut cursor) {
754        let (module_node, alias_node) = if imported.kind() == "aliased_import" {
755            let Some(name) = imported.child_by_field_name("name") else {
756                continue;
757            };
758            (name, imported.child_by_field_name("alias"))
759        } else {
760            (imported, None)
761        };
762        let alias = alias_node
763            .map(|alias| py_node_text(alias, source).trim().to_string())
764            .filter(|alias| !alias.is_empty());
765        let segments = python_path_segments(module_node, source);
766        if segments.is_empty() {
767            continue;
768        }
769        // `import a.b` binds `a`: the first segment's own token. A renamed
770        // import binds its alias token instead.
771        let binder_span = alias
772            .is_some()
773            .then_some(alias_node)
774            .flatten()
775            .or_else(|| python_first_segment_node(module_node))
776            .map(brokk_bifrost_core::analyzer::common::node_span);
777        let module = join_python_import_segments(&segments);
778        let identifier = alias.clone().or_else(|| segments.first().cloned());
779        infos.push(ImportInfo {
780            raw_snippet: if let Some(alias) = &alias {
781                format!("import {module} as {alias}")
782            } else {
783                format!("import {module}")
784            },
785            is_wildcard: false,
786            is_global: false,
787            identifier,
788            alias,
789            path: Some(StructuredImportPath {
790                segments,
791                kind: Some(StructuredImportPathKind::Namespace),
792                lexical_prefixes: Vec::new(),
793                lexical_scopes: Vec::new(),
794                declaration_start_byte: node.start_byte(),
795            }),
796            binder_span,
797        });
798    }
799    infos
800}
801
802fn python_from_import_infos(node: Node<'_>, source: &str) -> Vec<ImportInfo> {
803    let Some(module_node) = node.child_by_field_name("module_name") else {
804        return Vec::new();
805    };
806    let module_segments = python_module_segments(module_node, source);
807    if module_segments.is_empty() {
808        return Vec::new();
809    }
810
811    let mut infos = Vec::new();
812    let has_wildcard_import = {
813        let mut cursor = node.walk();
814        node.named_children(&mut cursor)
815            .any(|child| child.kind() == "wildcard_import")
816    };
817    let mut cursor = node.walk();
818    let imported_names: Vec<_> = node.children_by_field_name("name", &mut cursor).collect();
819    if has_wildcard_import {
820        let module = join_python_import_segments(&module_segments);
821        infos.push(ImportInfo {
822            raw_snippet: format!("from {module} import *"),
823            is_wildcard: true,
824            is_global: false,
825            identifier: None,
826            alias: None,
827            path: Some(StructuredImportPath {
828                segments: module_segments,
829                kind: Some(StructuredImportPathKind::ImportFrom),
830                lexical_prefixes: Vec::new(),
831                lexical_scopes: Vec::new(),
832                declaration_start_byte: node.start_byte(),
833            }),
834            binder_span: None,
835        });
836        return infos;
837    }
838    if imported_names.is_empty() {
839        return infos;
840    }
841
842    for imported in imported_names {
843        let (name_node, alias_node) = if imported.kind() == "aliased_import" {
844            let Some(name) = imported.child_by_field_name("name") else {
845                continue;
846            };
847            (name, imported.child_by_field_name("alias"))
848        } else {
849            (imported, None)
850        };
851        let alias = alias_node
852            .map(|alias| py_node_text(alias, source).trim().to_string())
853            .filter(|alias| !alias.is_empty());
854        let name_segments = python_path_segments(name_node, source);
855        if name_segments.is_empty() {
856            continue;
857        }
858        // `from m import x` binds `x`'s own token; a rename binds the alias
859        // token. A multi-segment imported name binds no single token.
860        let binder_span = alias
861            .is_some()
862            .then_some(alias_node)
863            .flatten()
864            .or_else(|| {
865                (name_segments.len() == 1)
866                    .then(|| python_first_segment_node(name_node))
867                    .flatten()
868            })
869            .map(brokk_bifrost_core::analyzer::common::node_span);
870        let imported_name = join_python_import_segments(&name_segments);
871        let mut segments = module_segments.clone();
872        segments.extend(name_segments);
873        let module = join_python_import_segments(&module_segments);
874        infos.push(ImportInfo {
875            raw_snippet: if let Some(alias) = &alias {
876                format!("from {module} import {imported_name} as {alias}")
877            } else {
878                format!("from {module} import {imported_name}")
879            },
880            is_wildcard: false,
881            is_global: false,
882            identifier: Some(alias.clone().unwrap_or_else(|| imported_name.clone())),
883            alias,
884            path: Some(StructuredImportPath {
885                segments,
886                kind: Some(StructuredImportPathKind::ImportFrom),
887                lexical_prefixes: Vec::new(),
888                lexical_scopes: Vec::new(),
889                declaration_start_byte: node.start_byte(),
890            }),
891            binder_span,
892        });
893    }
894    infos
895}
896
897fn python_module_segments(module: Node<'_>, source: &str) -> Vec<String> {
898    if module.kind() == "relative_import" {
899        let mut cursor = module.walk();
900        let mut prefix = String::new();
901        let mut path_node = None;
902        for child in module.named_children(&mut cursor) {
903            match child.kind() {
904                "import_prefix" if prefix.is_empty() => {
905                    prefix = py_node_text(child, source).trim().to_string();
906                }
907                "dotted_name" if path_node.is_none() => {
908                    path_node = Some(child);
909                }
910                _ => {}
911            }
912        }
913        let mut segments = path_node
914            .map(|path| python_path_segments(path, source))
915            .unwrap_or_default();
916        if !prefix.is_empty() {
917            if let Some(first) = segments.first_mut() {
918                first.insert_str(0, &prefix);
919            } else {
920                segments.push(prefix);
921            }
922        }
923        return segments;
924    }
925    python_path_segments(module, source)
926}
927
928/// The token that spells a path's first segment: the identifier itself, or a
929/// dotted name's first identifier. `None` when the shape has no leading
930/// identifier token of its own (e.g. a relative-import prefix).
931fn python_first_segment_node(node: Node<'_>) -> Option<Node<'_>> {
932    match node.kind() {
933        "identifier" => Some(node),
934        "dotted_name" => {
935            let mut cursor = node.walk();
936            node.named_children(&mut cursor)
937                .find(|child| child.kind() == "identifier")
938        }
939        _ => None,
940    }
941}
942
943fn python_path_segments(node: Node<'_>, source: &str) -> Vec<String> {
944    match node.kind() {
945        "identifier" => vec![py_node_text(node, source).trim().to_string()],
946        "dotted_name" => {
947            let mut segments = Vec::new();
948            let mut cursor = node.walk();
949            for child in node.named_children(&mut cursor) {
950                segments.extend(python_path_segments(child, source));
951            }
952            segments
953        }
954        _ => {
955            let mut segments = Vec::new();
956            let mut cursor = node.walk();
957            for child in node.named_children(&mut cursor) {
958                segments.extend(python_path_segments(child, source));
959            }
960            segments
961        }
962    }
963}
964
965fn join_python_import_segments(segments: &[String]) -> String {
966    let Some((first, rest)) = segments.split_first() else {
967        return String::new();
968    };
969    if first.starts_with('.') && !rest.is_empty() {
970        format!("{first}.{}", rest.join("."))
971    } else {
972        segments.join(".")
973    }
974}
975
976pub fn python_namespace_binding_name(
977    import: &ImportInfo,
978    alias: Option<&str>,
979    module: &str,
980) -> String {
981    import
982        .identifier
983        .clone()
984        .or_else(|| alias.map(str::to_string))
985        .unwrap_or_else(|| module.to_string())
986}
987
988pub fn python_namespace_binding_module(
989    import: &ImportInfo,
990    alias: Option<&str>,
991    module: &str,
992) -> String {
993    if alias.is_some() {
994        return module.to_string();
995    }
996    import
997        .path
998        .as_ref()
999        .and_then(|path| path.segments.first().cloned())
1000        .unwrap_or_else(|| module.to_string())
1001}
1002
1003pub fn resolve_python_relative_module(
1004    source_file: &ProjectFile,
1005    module_expr: &str,
1006) -> Option<String> {
1007    resolve_python_relative_module_from_package(&python_current_package(source_file), module_expr)
1008}
1009
1010/// Resolve a structured Python module expression against an already-known
1011/// package identity. Dependency-pack producers know module identities without
1012/// owning a workspace [`ProjectFile`], so they use this entry point.
1013pub fn resolve_python_relative_module_from_package(
1014    current_package: &str,
1015    module_expr: &str,
1016) -> Option<String> {
1017    let level = module_expr.chars().take_while(|ch| *ch == '.').count();
1018    let suffix = module_expr[level..].trim_matches('.');
1019    let mut parts: Vec<_> = current_package
1020        .split('.')
1021        .filter(|part| !part.is_empty())
1022        .map(str::to_string)
1023        .collect();
1024    if level == 0 {
1025        return Some(module_expr.to_string());
1026    }
1027    if level > 0 {
1028        if level - 1 > parts.len() {
1029            return None;
1030        }
1031        parts.truncate(parts.len() - (level - 1));
1032    }
1033    if !suffix.is_empty() {
1034        parts.extend(suffix.split('.').map(str::to_string));
1035    }
1036    Some(parts.join("."))
1037}
1038
1039fn python_current_package(source_file: &ProjectFile) -> String {
1040    let module = python_module_name(source_file);
1041    if source_file
1042        .rel_path()
1043        .file_name()
1044        .and_then(|name| name.to_str())
1045        == Some("__init__.py")
1046    {
1047        module
1048    } else {
1049        module
1050            .rsplit_once('.')
1051            .map(|(package, _)| package.to_string())
1052            .unwrap_or_default()
1053    }
1054}