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