Skip to main content

brokk_bifrost_python/
declarations.rs

1use crate::bindings::python_direct_scope_bindings_bounded;
2use crate::imports::{python_import_infos_from_node, python_import_syntaxes_from_node};
3use crate::syntax::{
4    PythonOverloadDecoratorBindings, PythonOverloadDecoratorName, expression_name_node,
5    python_parameter_name, python_plain_string_literal,
6};
7use brokk_bifrost_core::analyzer::fq_name::{FqName, SegmentKind, segment_interner};
8use brokk_bifrost_core::analyzer::model::{
9    CallableArity, CodeUnitType, DispatchExtensibility, ParameterMetadata, SignatureMetadata,
10    StructuredImportPathKind, StructuredTypeIdentity,
11};
12use brokk_bifrost_core::analyzer::parsed_file::{
13    ParsedFile, ParsedSourceFacts, SourceDeclarationMetadataLink, SourceImportFact,
14};
15use brokk_bifrost_core::analyzer::project::Project;
16use brokk_bifrost_core::analyzer::python_facts::{PythonCallableReturnFact, PythonSourceFacts};
17use brokk_bifrost_core::analyzer::rust_facts::RustItemSourceFacts;
18use brokk_bifrost_core::analyzer::source_facts::{
19    PrimarySourceFactCollector, SourceDeclarationId, SourceImportId,
20};
21use brokk_bifrost_core::analyzer::structural::callable::CallSiteContext;
22use brokk_bifrost_core::analyzer::structural::collector::StructuralFactCollector;
23use brokk_bifrost_core::analyzer::structural::resolution::DeclaredVisibility;
24use brokk_bifrost_core::analyzer::structural::spec::{CompiledKinds, StructuralSpec};
25use brokk_bifrost_core::analyzer::tree_walk::{ParentIndex, WalkControl, walk_named_tree_preorder};
26use brokk_bifrost_core::analyzer::{CodeUnit, ProjectFile};
27use brokk_bifrost_core::hash::{HashMap, HashSet};
28use brokk_bifrost_core::path_normalization::NormalizePath;
29use brokk_bifrost_core::text_utils::{compute_line_starts, find_line_index_for_offset};
30use std::path::{Path, PathBuf};
31use tree_sitter::{Node, Parser, Tree};
32
33use crate::source_properties::{python_annotation_references, python_return_type_identity};
34use crate::structural::{PYTHON_STRUCTURAL_SPEC, python_parenthesized_expression_child};
35
36/// Build the structured module-path prefix for a Python declaration.
37///
38/// Ordinary modules render as a dotted path such as `mypkg.subpkg.mymodule`,
39/// with each original path component represented by one
40/// [`SegmentKind::Package`] segment. Hidden directories such as `.agent` and
41/// `.github` are also legal components in the analyzer's path-derived Python
42/// convention, but their leading dot is ambiguous after a rendered name has
43/// been joined.
44///
45/// Build the structured name from the file path's original components so
46/// hidden-directory segments stay intact in cold extraction, synthesized module
47/// units, and persisted reconstruction.
48pub fn python_module_fq(project: &dyn Project, file: &ProjectFile) -> FqName {
49    FqName::from_package_components(&python_module_components(project, file))
50}
51
52fn python_module_components(project: &dyn Project, file: &ProjectFile) -> Vec<String> {
53    python_module_components_from_root(file, &python_import_root(project, file))
54}
55
56/// The module-name components `file`'s path yields when it is named from the
57/// project root, which is the longest name the workspace can give it:
58/// `dependency/core/lib/common/__init__.py` is
59/// `["dependency", "core", "lib", "common"]`.
60///
61/// A Python module name is relative to the `sys.path` entry an import of it
62/// resolves against, so one file has one valid absolute name per ancestor
63/// directory on that path, and each of them is a suffix of this one.
64/// [`python_import_root`] picks the single name that becomes the module's
65/// identity; this is what an import written against any deeper root has to be
66/// matched against (#3506).
67pub fn python_project_module_components(file: &ProjectFile) -> Vec<String> {
68    python_module_components_from_root(file, Path::new(""))
69}
70
71/// `file`'s module-name components relative to `import_root_rel`, which must be
72/// a prefix of the file's own relative path.
73fn python_module_components_from_root(file: &ProjectFile, import_root_rel: &Path) -> Vec<String> {
74    let mut components = python_package_components_for_file(file, import_root_rel);
75    let module_name = file
76        .rel_path()
77        .file_stem()
78        .and_then(|stem| stem.to_str())
79        .unwrap_or_default();
80    if module_name != "__init__" || components.is_empty() {
81        components.push(module_name.to_string());
82    }
83    components
84}
85
86/// The directory `file`'s path-derived Python module name is relative to: the
87/// analyzer's stand-in for the `sys.path` entry an absolute import of that
88/// module would be resolved against.
89///
90/// It is the nearest configured setuptools import root, else the parent of the
91/// outermost `__init__.py` package chain above the file, else the project root.
92/// The result is always a prefix of the file's own relative path, and it is
93/// what makes two same-named modules in one snapshot distinguishable:
94/// `registry/purview-registry/registry/models.py` and
95/// `registry/sql-registry/registry/models.py` are both the module
96/// `registry.models`, but they belong to different roots (#3475).
97pub fn python_import_root(project: &dyn Project, file: &ProjectFile) -> PathBuf {
98    let Some(parent_rel) = file.rel_path().parent() else {
99        return PathBuf::new();
100    };
101    if parent_rel.as_os_str().is_empty() {
102        return PathBuf::new();
103    }
104
105    if let Some(import_root_rel) = python_configured_import_root(project, file, parent_rel) {
106        return import_root_rel;
107    }
108
109    let mut effective_package_root_rel: Option<&Path> = None;
110    let mut current_rel = Some(parent_rel);
111    while let Some(path) = current_rel {
112        if project
113            .file_by_rel_path(&path.join("__init__.py"))
114            .is_some()
115        {
116            effective_package_root_rel = Some(path);
117        }
118        current_rel = path.parent();
119    }
120
121    // A package root's own parent is the import root; a file in no package at
122    // all is named from the project root.
123    effective_package_root_rel
124        .and_then(Path::parent)
125        .unwrap_or_else(|| Path::new(""))
126        .to_path_buf()
127}
128
129fn python_package_components_for_file(file: &ProjectFile, import_root_rel: &Path) -> Vec<String> {
130    let Some(parent_rel) = file.rel_path().parent() else {
131        return Vec::new();
132    };
133    let relative_package = parent_rel
134        .strip_prefix(import_root_rel)
135        .expect("a Python import root is a prefix of the paths it names modules for");
136    path_components(relative_package)
137}
138
139/// Find the nearest setuptools import root that contains this source file.
140///
141/// `pyproject.toml` roots take precedence over legacy `setup.py` evidence at
142/// each ancestor. An unrelated or malformed packaging file does not change the
143/// existing `__init__.py` package-root convention.
144fn python_configured_import_root(
145    project: &dyn Project,
146    file: &ProjectFile,
147    parent_rel: &Path,
148) -> Option<PathBuf> {
149    let mut manifest_dir_rel = Some(parent_rel);
150    while let Some(directory) = manifest_dir_rel {
151        let manifest_dir = file.root().join(directory);
152        let mut roots = snapshot_source(project, &directory.join("pyproject.toml"))
153            .map(|source| parse_setuptools_where_entries(&source))
154            .unwrap_or_default()
155            .iter()
156            .map(|entry| manifest_dir.join(entry).normalize())
157            .filter_map(|root| root.strip_prefix(file.root()).ok().map(Path::to_path_buf))
158            .filter(|root| parent_rel.starts_with(root))
159            .collect::<Vec<_>>();
160        roots.sort_by_key(|root| root.components().count());
161        if let Some(root) = roots.pop() {
162            return Some(root);
163        }
164        if let Some(package_dir) = snapshot_source(project, &directory.join("setup.py"))
165            .and_then(|source| parse_setuptools_setup_py_import_root(&source))
166        {
167            let root = manifest_dir.join(package_dir).normalize();
168            if let Ok(root) = root.strip_prefix(file.root())
169                && parent_rel.starts_with(root)
170            {
171                return Some(root.to_path_buf());
172            }
173        }
174        manifest_dir_rel = directory.parent();
175    }
176    None
177}
178
179/// The root a packaging manifest declares for this file, whether that root is
180/// the project root itself or a directory below it.
181///
182/// `pyproject.toml`'s `tool.setuptools.packages.find.where` and `setup.py`'s
183/// `package_dir` state where a project's packages begin: a file below one of
184/// them is named from that directory and from nowhere else. `where = ["."]`
185/// declares the project root, and a declaration of the project root is a
186/// declaration like any other: it says this project's packages begin here, so a
187/// shorter spelling below that root names a different arrangement of the same
188/// directories. Only a project whose packaging configuration does not cover
189/// this file answers `None` (#3506).
190pub fn python_declared_import_root(project: &dyn Project, file: &ProjectFile) -> Option<PathBuf> {
191    let parent_rel = file.rel_path().parent()?;
192    if parent_rel.as_os_str().is_empty() {
193        return None;
194    }
195    python_configured_import_root(project, file, parent_rel)
196}
197
198/// The directory the package a repeated spelling starts from has to add to its
199/// own `__path__` for `source_root_rel` to be a root the specifier is written
200/// against, or `None` when the specifier does not spell that root's own path
201/// again at its head (#3506).
202///
203/// A packaging declaration states where a project's packages begin, so a
204/// shorter spelling below it normally names a different arrangement of the same
205/// directories rather than the module: `otherpkg/vendor/api.py` under
206/// `where = ["."]` is `otherpkg.vendor.api`, and `vendor.api`, whose implied
207/// root is `otherpkg`, is not another name for it. The double-nested
208/// distribution directory is the one shape a declaration leaves open, because
209/// the specifier writes the implied root's own path again before the module's
210/// path: from `rtdetr_pose/`, `rtdetr_pose.config` names `config.py` in the
211/// same-named package that directory holds, the file the workspace indexes as
212/// `rtdetr_pose.rtdetr_pose.config`.
213///
214/// The shape is a naming question only, and it is not evidence that the import
215/// works. The interpreter finds the module through the package the specifier
216/// starts from -- `rtdetr_pose/__init__.py` -- and only that package's own
217/// statement that `rtdetr_pose/rtdetr_pose` is on its search path makes the
218/// shorter spelling name the file. The repeated directory name and the empty
219/// markers that make the directories packages state nothing on their own, so
220/// this returns the directory such a statement has to name, relative to the
221/// project root, and the snapshot decides whether the marker states it.
222pub(crate) fn python_repeated_root_extension_dir(
223    file: &ProjectFile,
224    source_root_rel: &Path,
225    declared_root_rel: &Path,
226) -> Option<PathBuf> {
227    let implied_rel = source_root_rel.strip_prefix(declared_root_rel).ok()?;
228    let implied = path_components(implied_rel);
229    if implied.is_empty()
230        || !python_module_components_from_root(file, source_root_rel).starts_with(&implied)
231    {
232        return None;
233    }
234    Some(source_root_rel.join(implied_rel))
235}
236
237// Root selection is snapshot placement, not a content-only source fact. Read
238// through the selected project and keep the parsed roots within this operation.
239fn snapshot_source(project: &dyn Project, relative_path: &Path) -> Option<String> {
240    let file = project.file_by_rel_path(relative_path)?;
241    project.read_source(&file).ok()
242}
243
244/// Interpret a legacy setuptools root statically, without executing setup.py.
245/// A direct top-level call to an imported setuptools or distutils.core setup
246/// binding must supply packages. A literal package_dir establishes the root;
247/// otherwise packages must be a nonempty literal collection or supported
248/// discovery call. Unsupported shapes and shadowed imports declare no root.
249fn parse_setuptools_setup_py_import_root(source: &str) -> Option<PathBuf> {
250    let tree = parse_python_tree(source)?;
251    let root = tree.root_node();
252    if root.has_error() {
253        return None;
254    }
255    let mut import_root = None;
256    for (call, setup_bindings) in setup_py_setup_calls(source, root) {
257        let candidate = setup_py_import_root_from_call(call, source, &setup_bindings)?;
258        if import_root
259            .replace(candidate.clone())
260            .is_some_and(|root| root != candidate)
261        {
262            return None;
263        }
264    }
265    import_root
266}
267
268/// Read the `python_requires` specifier a legacy `setup.py` declares.
269///
270/// This is setuptools' spelling of `pyproject.toml`'s `requires-python`, and it
271/// is what selects the standard-library semantic pack for a project that has no
272/// PEP 621 manifest. The script is read, never executed: only a literal string
273/// passed to a top-level call to an imported `setup` binding counts, and two
274/// calls that disagree declare nothing.
275pub fn setuptools_setup_py_python_requires(setup_py: &Path) -> Option<String> {
276    let source = std::fs::read_to_string(setup_py).ok()?;
277    setuptools_setup_py_python_requires_from_source(&source)
278}
279
280/// Snapshot-fed counterpart of the legacy declaration reader.
281pub fn setuptools_setup_py_python_requires_from_source(source: &str) -> Option<String> {
282    let tree = parse_python_tree(source)?;
283    let root = tree.root_node();
284    if root.has_error() {
285        return None;
286    }
287    let mut requirement: Option<String> = None;
288    for (call, _) in setup_py_setup_calls(source, root) {
289        let Some(arguments) = call.child_by_field_name("arguments") else {
290            continue;
291        };
292        if arguments.kind() != "argument_list" {
293            continue;
294        }
295        let mut cursor = arguments.walk();
296        for argument in arguments.named_children(&mut cursor) {
297            if argument.kind() != "keyword_argument" {
298                continue;
299            }
300            let Some(name) = argument.child_by_field_name("name") else {
301                continue;
302            };
303            if py_node_text(name, source).trim() != "python_requires" {
304                continue;
305            }
306            let value = argument.child_by_field_name("value")?;
307            let declared = python_plain_string_literal(value, source)?.to_owned();
308            if requirement
309                .replace(declared.clone())
310                .is_some_and(|previous| previous != declared)
311            {
312                return None;
313            }
314        }
315    }
316    requirement
317}
318
319/// One supported setuptools keyword, with its original AST range. Unknown
320/// expressions remain `None`; this function never executes the script.
321#[derive(Debug, Clone)]
322pub struct SetuptoolsLiteralArgument {
323    pub name: String,
324    pub value: Option<SetuptoolsLiteralValue>,
325    pub byte_range: std::ops::Range<usize>,
326    pub call_index: usize,
327}
328
329#[derive(Debug, Clone)]
330pub enum SetuptoolsLiteralValue {
331    String(String),
332    Strings(Vec<String>),
333    Extras(Vec<(String, Vec<String>)>),
334}
335
336pub fn setuptools_setup_py_literal_arguments(
337    source: &str,
338) -> Option<Vec<SetuptoolsLiteralArgument>> {
339    let tree = parse_python_tree(source)?;
340    let root = tree.root_node();
341    if root.has_error() {
342        return None;
343    }
344    let mut output = Vec::new();
345    for (call_index, (call, _)) in setup_py_setup_calls(source, root).into_iter().enumerate() {
346        let arguments = call.child_by_field_name("arguments")?;
347        let mut cursor = arguments.walk();
348        for argument in arguments.named_children(&mut cursor) {
349            if argument.kind() == "dictionary_splat" {
350                output.push(SetuptoolsLiteralArgument {
351                    name: "**kwargs".to_owned(),
352                    value: None,
353                    byte_range: argument.byte_range(),
354                    call_index,
355                });
356                continue;
357            }
358            if argument.kind() != "keyword_argument" {
359                continue;
360            }
361            let name = py_node_text(argument.child_by_field_name("name")?, source).to_owned();
362            let node = argument.child_by_field_name("value")?;
363            let value = match name.as_str() {
364                "name" | "version" | "python_requires" => python_plain_string_literal(node, source)
365                    .map(|value| SetuptoolsLiteralValue::String(value.to_owned())),
366                "install_requires" | "setup_requires" | "tests_require" => {
367                    setuptools_literal_strings(node, source).map(SetuptoolsLiteralValue::Strings)
368                }
369                "extras_require" => {
370                    let mut entries = Vec::new();
371                    let mut cursor = node.walk();
372                    let mut complete = node.kind() == "dictionary";
373                    for pair in node.named_children(&mut cursor) {
374                        let entry = pair
375                            .child_by_field_name("key")
376                            .and_then(|key| python_plain_string_literal(key, source))
377                            .zip(
378                                pair.child_by_field_name("value")
379                                    .and_then(|value| setuptools_literal_strings(value, source)),
380                            );
381                        if let Some((key, values)) = entry {
382                            entries.push((key.to_owned(), values));
383                        } else {
384                            complete = false;
385                        }
386                    }
387                    complete.then_some(SetuptoolsLiteralValue::Extras(entries))
388                }
389                _ => continue,
390            };
391            output.push(SetuptoolsLiteralArgument {
392                name,
393                value,
394                byte_range: node.byte_range(),
395                call_index,
396            });
397        }
398    }
399    Some(output)
400}
401
402fn setuptools_literal_strings(node: Node<'_>, source: &str) -> Option<Vec<String>> {
403    if !matches!(node.kind(), "list" | "tuple") {
404        return None;
405    }
406    let mut cursor = node.walk();
407    node.named_children(&mut cursor)
408        .filter(|child| child.kind() != "comment")
409        .map(|child| python_plain_string_literal(child, source).map(str::to_owned))
410        .collect()
411}
412
413/// Every top-level call to an imported setuptools `setup` binding, paired with
414/// the import bindings that were live where the call appears.
415///
416/// The walk tracks bindings across the module's top-level statements, so a name
417/// a later statement rebinds stops being read as setuptools' `setup`. It does
418/// not enter function, class, or lambda bodies: a call there is conditional on
419/// something this reader does not evaluate.
420fn setup_py_setup_calls<'tree>(
421    source: &str,
422    root: Node<'tree>,
423) -> Vec<(Node<'tree>, HashMap<Vec<String>, String>)> {
424    let mut setup_bindings: HashMap<Vec<String>, String> = HashMap::default();
425    let mut calls = Vec::new();
426    let mut cursor = root.walk();
427
428    for statement in root.named_children(&mut cursor) {
429        if matches!(
430            statement.kind(),
431            "import_statement" | "import_from_statement"
432        ) {
433            for binding in setup_py_bound_names(statement, source) {
434                setup_bindings.retain(|path, _| path.first() != Some(&binding));
435            }
436            for import in python_import_infos_from_node(statement, source) {
437                if import.is_wildcard {
438                    setup_bindings.clear();
439                    continue;
440                }
441                let Some(path) = import.path else { continue };
442                let segments = path.segments.iter().map(String::as_str).collect::<Vec<_>>();
443                match path.kind {
444                    Some(StructuredImportPathKind::ImportFrom) => {
445                        let function_name = match segments.as_slice() {
446                            ["setuptools", "setup"] | ["distutils", "core", "setup"] => "setup",
447                            ["setuptools", "find_packages"] => "find_packages",
448                            ["setuptools", "find_namespace_packages"] => "find_namespace_packages",
449                            _ => continue,
450                        };
451                        setup_bindings.insert(
452                            vec![import.identifier.expect("imported function binds a name")],
453                            function_name.to_string(),
454                        );
455                    }
456                    Some(StructuredImportPathKind::Namespace) => {
457                        let function_names = match segments.as_slice() {
458                            ["setuptools"] => {
459                                ["setup", "find_packages", "find_namespace_packages"].as_slice()
460                            }
461                            ["distutils", "core"] => ["setup"].as_slice(),
462                            _ => continue,
463                        };
464                        let binding_prefix = import
465                            .alias
466                            .map(|alias| vec![alias])
467                            .unwrap_or_else(|| path.segments.clone());
468                        for function_name in function_names {
469                            let mut callable = binding_prefix.clone();
470                            callable.push((*function_name).to_string());
471                            setup_bindings.insert(callable, (*function_name).to_string());
472                        }
473                    }
474                    _ => continue,
475                }
476            }
477            continue;
478        }
479
480        if statement.kind() == "expression_statement"
481            && statement.named_child_count() == 1
482            && let Some(call) = statement.named_child(0)
483            && call.kind() == "call"
484            && setup_py_call_imported_function(call, source, &setup_bindings)
485                .is_some_and(|function| function == "setup")
486        {
487            calls.push((call, setup_bindings.clone()));
488        }
489
490        for binding in setup_py_bound_names(statement, source) {
491            setup_bindings.retain(|path, _| path.first() != Some(&binding));
492        }
493    }
494    calls
495}
496
497/// Return names that a top-level statement binds in the module scope.
498///
499/// The walk is iterative and does not enter function, class, or lambda bodies.
500/// Bindings in control-flow statements still invalidate an imported setup name:
501/// their execution is conditional, so retaining the import would overclaim its
502/// identity at a later top-level call.
503fn setup_py_bound_names(statement: Node<'_>, source: &str) -> Vec<String> {
504    let mut names = Vec::new();
505    let mut pending = vec![statement];
506    while let Some(node) = pending.pop() {
507        for binding in python_direct_scope_bindings_bounded(node, source, || true)
508            .expect("unbounded setup.py binding walk")
509        {
510            let name = py_node_text(binding.declaration, source).trim();
511            if !name.is_empty() {
512                names.push(name.to_string());
513            }
514        }
515        let excluded_body = matches!(
516            node.kind(),
517            "function_definition" | "class_definition" | "lambda"
518        )
519        .then(|| node.child_by_field_name("body").map(|body| body.id()))
520        .flatten();
521        let mut cursor = node.walk();
522        pending.extend(
523            node.named_children(&mut cursor)
524                .filter(|child| Some(child.id()) != excluded_body),
525        );
526    }
527    names
528}
529
530fn setup_py_call_imported_function<'a>(
531    call: Node<'_>,
532    source: &str,
533    setup_bindings: &'a HashMap<Vec<String>, String>,
534) -> Option<&'a str> {
535    let mut function = call.child_by_field_name("function")?;
536    let mut path = Vec::new();
537    while function.kind() == "attribute" {
538        let attribute = function.child_by_field_name("attribute")?;
539        path.push(py_node_text(attribute, source).to_string());
540        let object = function.child_by_field_name("object")?;
541        function = object;
542    }
543    if function.kind() != "identifier" {
544        return None;
545    }
546    path.push(py_node_text(function, source).to_string());
547    path.reverse();
548    setup_bindings.get(&path).map(String::as_str)
549}
550
551fn setup_py_import_root_from_call(
552    call: Node<'_>,
553    source: &str,
554    setup_bindings: &HashMap<Vec<String>, String>,
555) -> Option<PathBuf> {
556    let arguments = call.child_by_field_name("arguments")?;
557    if arguments.kind() != "argument_list" {
558        return None;
559    }
560
561    let mut packages = None;
562    let mut package_dir = None;
563    let mut cursor = arguments.walk();
564    for argument in arguments.named_children(&mut cursor) {
565        if argument.kind() == "comment" {
566            continue;
567        }
568        // Positional dictionaries and expansions can supply packaging options.
569        if argument.kind() != "keyword_argument" {
570            return None;
571        }
572        let name = argument.child_by_field_name("name")?;
573        let value = argument.child_by_field_name("value")?;
574        match py_node_text(name, source).trim() {
575            "packages" if packages.is_none() => packages = Some(value),
576            "packages" => return None,
577            "package_dir" if package_dir.is_none() => package_dir = Some(value),
578            "package_dir" => return None,
579            _ => {}
580        }
581    }
582
583    let packages = packages?;
584    if let Some(package_dir) = package_dir {
585        return setup_py_static_package_dir(package_dir, source);
586    }
587    if setup_py_nonempty_literal_packages(packages, source) {
588        return Some(PathBuf::new());
589    }
590    setup_py_discovery_root_from_call(packages, source, setup_bindings)
591}
592
593fn setup_py_discovery_root_from_call(
594    call: Node<'_>,
595    source: &str,
596    setup_bindings: &HashMap<Vec<String>, String>,
597) -> Option<PathBuf> {
598    let function = setup_py_call_imported_function(call, source, setup_bindings)?;
599    if !matches!(function, "find_packages" | "find_namespace_packages") {
600        return None;
601    }
602    let arguments = call.child_by_field_name("arguments")?;
603    if arguments.kind() != "argument_list" {
604        return None;
605    }
606
607    let mut where_value = None;
608    let mut seen_exclude = false;
609    let mut seen_include = false;
610    let mut positional_index = 0;
611    let mut cursor = arguments.walk();
612    for argument in arguments.named_children(&mut cursor) {
613        if argument.kind() == "comment" {
614            continue;
615        }
616        if matches!(argument.kind(), "list_splat" | "dictionary_splat") {
617            return None;
618        }
619        if argument.kind() == "keyword_argument" {
620            let name = py_node_text(argument.child_by_field_name("name")?, source).trim();
621            let value = argument.child_by_field_name("value")?;
622            match name {
623                "where" if where_value.is_none() => where_value = Some(value),
624                "where" => return None,
625                "exclude" if !seen_exclude => seen_exclude = true,
626                "exclude" => return None,
627                "include" if !seen_include => seen_include = true,
628                "include" => return None,
629                _ => return None,
630            }
631            continue;
632        }
633
634        let slot = positional_index;
635        positional_index += 1;
636        match slot {
637            0 if where_value.is_none() => where_value = Some(argument),
638            0 => return None,
639            1 if !seen_exclude => seen_exclude = true,
640            1 => return None,
641            2 if !seen_include => seen_include = true,
642            2 => return None,
643            _ => return None,
644        }
645    }
646
647    where_value
648        .map(|value| python_plain_string_literal(value, source))
649        .unwrap_or(Some(""))
650        .map(PathBuf::from)
651}
652
653fn setup_py_nonempty_literal_packages(value: Node<'_>, source: &str) -> bool {
654    let value = setup_py_unwrap_parenthesized(value);
655    if !matches!(value.kind(), "list" | "set" | "tuple") {
656        return false;
657    }
658    let mut cursor = value.walk();
659    let mut nonempty = false;
660    for element in value.named_children(&mut cursor) {
661        if element.kind() == "comment" {
662            continue;
663        }
664        let Some(package) = python_plain_string_literal(element, source) else {
665            return false;
666        };
667        if package.is_empty() {
668            return false;
669        }
670        nonempty = true;
671    }
672    nonempty
673}
674
675fn setup_py_static_package_dir(value: Node<'_>, source: &str) -> Option<PathBuf> {
676    let value = setup_py_unwrap_parenthesized(value);
677    if value.kind() != "dictionary" {
678        return None;
679    }
680    let mut cursor = value.walk();
681    let mut pairs = value
682        .named_children(&mut cursor)
683        .filter(|node| node.kind() != "comment");
684    let Some(pair) = pairs.next() else {
685        return Some(PathBuf::new());
686    };
687    if pairs.next().is_some() || pair.kind() != "pair" {
688        return None;
689    }
690    let key = python_plain_string_literal(pair.child_by_field_name("key")?, source)?;
691    if !key.is_empty() {
692        return None;
693    }
694    let root = python_plain_string_literal(pair.child_by_field_name("value")?, source)?;
695    let root = PathBuf::from(root);
696    (!root.is_absolute()).then_some(root)
697}
698
699fn setup_py_unwrap_parenthesized(node: Node<'_>) -> Node<'_> {
700    brokk_bifrost_core::analyzer::tree_walk::unwrap_parenthesized_expression_with(
701        node,
702        python_parenthesized_expression_child,
703    )
704}
705
706fn parse_setuptools_where_entries(source: &str) -> Vec<String> {
707    let Ok(document) = source.parse::<toml::Value>() else {
708        return Vec::new();
709    };
710    document
711        .get("tool")
712        .and_then(|tool| tool.get("setuptools"))
713        .and_then(|setuptools| setuptools.get("packages"))
714        .and_then(|packages| packages.get("find"))
715        .and_then(|find| find.get("where"))
716        .and_then(toml::Value::as_array)
717        .map(|entries| {
718            entries
719                .iter()
720                .filter_map(toml::Value::as_str)
721                .map(str::to_string)
722                .collect()
723        })
724        .unwrap_or_default()
725}
726
727fn path_components(path: &Path) -> Vec<String> {
728    path.components()
729        .map(|component| component.as_os_str().to_string_lossy().to_string())
730        .filter(|component| !component.is_empty())
731        .collect()
732}
733
734pub fn python_is_decorated_function_boundary(node: Node<'_>) -> bool {
735    if node.kind() != "decorated_definition" {
736        return false;
737    }
738    let mut cursor = node.walk();
739    node.named_children(&mut cursor)
740        .any(|child| child.kind() == "function_definition")
741}
742
743#[derive(Clone)]
744pub struct Scope {
745    kind: ScopeKind,
746    path: String,
747    /// The structured qualified name matching `path`. Tracked independently of
748    /// whether this scope level was actually `capture`d as a `CodeUnit`, so a
749    /// nested class/function that IS captured can always extend an ancestor's
750    /// `fq` even when an intermediate scope level (e.g. a non-captured nested
751    /// function) has no `code_unit` of its own to read `.fq()` from.
752    fq: FqName,
753    code_unit: Option<CodeUnit>,
754    method_receiver: Option<String>,
755}
756
757#[derive(Clone, Copy, PartialEq, Eq)]
758enum ScopeKind {
759    Class,
760    Function,
761}
762
763pub struct PythonVisitor<'source, 'parsed, 'structural>
764where
765    'source: 'structural,
766{
767    pub file: &'parsed ProjectFile,
768    pub source: &'source str,
769    pub package_name: &'parsed str,
770    module_fq: &'parsed FqName,
771    pub parsed: &'parsed mut ParsedFile,
772    pub module: Option<CodeUnit>,
773    overload_decorators: PythonOverloadDecoratorBindings,
774    source_facts: PrimarySourceFactCollector<'source>,
775    structural: StructuralFactCollector<'structural, 'structural>,
776    structural_kinds: &'structural CompiledKinds,
777    call_site_context: &'structural CallSiteContext,
778    structural_parents: Vec<Option<u32>>,
779    source_imports: Vec<SourceImportFact>,
780    generic_imports: Vec<SourceImportId>,
781    source_declaration_units: Vec<(SourceDeclarationId, CodeUnit)>,
782    // Source links can outlive the indexed unit when an enclosing class is replaced.
783    // This membership belongs only to the current file walk.
784    source_declaration_unit_keys: HashSet<CodeUnit>,
785    python_source_facts: PythonSourceFacts,
786    python_callable_return_by_node: HashMap<usize, usize>,
787    overload_metadata: Vec<(CodeUnit, usize, Vec<PythonOverloadDecoratorName>)>,
788}
789
790struct PythonFrame<'tree> {
791    node: Node<'tree>,
792    scope: Vec<Scope>,
793    module_control_depth: usize,
794    declaration: bool,
795    wrapper: Option<Node<'tree>>,
796    lexical_scopes: Vec<Node<'tree>>,
797}
798
799enum PythonWork<'tree> {
800    Enter(PythonFrame<'tree>),
801    Exit,
802}
803
804struct DeclarationChildren<'tree> {
805    body: Option<(Node<'tree>, Vec<Scope>)>,
806    container_scope: Option<Vec<Scope>>,
807    container_depth: usize,
808}
809
810impl<'source, 'parsed, 'structural> PythonVisitor<'source, 'parsed, 'structural>
811where
812    'source: 'structural,
813{
814    pub fn visit_container(
815        &mut self,
816        node: Node<'_>,
817        scope: &[Scope],
818        module_control_depth: usize,
819    ) {
820        let mut stack = vec![PythonWork::Enter(PythonFrame {
821            node,
822            scope: scope.to_vec(),
823            module_control_depth,
824            declaration: true,
825            wrapper: None,
826            lexical_scopes: Vec::new(),
827        })];
828        while let Some(work) = stack.pop() {
829            let PythonWork::Enter(frame) = work else {
830                self.structural_parents
831                    .pop()
832                    .expect("Python structural parent stack must balance");
833                continue;
834            };
835            let structural_parent = self.structural_parents.last().copied().flatten();
836            let structural_id = self.admit_structural(frame.node, structural_parent);
837            self.structural_parents
838                .push(structural_id.or(structural_parent));
839
840            if frame.node.kind() == "identifier" {
841                let text = py_node_text(frame.node, self.source).trim();
842                if !text.is_empty() {
843                    self.parsed.type_identifiers.insert(text.to_string());
844                }
845            }
846
847            if frame.node.kind() == "function_definition" {
848                self.capture_function_return_fact(frame.node, frame.wrapper);
849            }
850
851            if matches!(
852                frame.node.kind(),
853                "import_statement" | "import_from_statement"
854            ) {
855                self.visit_import_statement(frame.node, &frame.lexical_scopes);
856            }
857
858            let declaration_children = if frame.declaration {
859                self.visit_statement(
860                    frame.node,
861                    &frame.scope,
862                    frame.module_control_depth,
863                    frame.wrapper,
864                )
865            } else {
866                DeclarationChildren {
867                    body: None,
868                    container_scope: None,
869                    container_depth: frame.module_control_depth,
870                }
871            };
872
873            let mut children = Vec::new();
874            let mut cursor = frame.node.walk();
875            for child in frame.node.named_children(&mut cursor) {
876                let mut child_scope = frame.scope.clone();
877                let mut declaration = false;
878                let mut wrapper = None;
879                let mut child_depth = frame.module_control_depth;
880
881                if let Some((body, scope)) = &declaration_children.body
882                    && child.id() == body.id()
883                {
884                    declaration = true;
885                    child_scope = scope.clone();
886                } else if let Some(scope) = &declaration_children.container_scope {
887                    declaration = true;
888                    child_scope = scope.clone();
889                    child_depth = declaration_children.container_depth;
890                } else if frame.declaration
891                    && frame.node.kind() == "decorated_definition"
892                    && matches!(child.kind(), "class_definition" | "function_definition")
893                {
894                    // The wrapper owns the source range, while its concrete
895                    // definition owns the declaration semantics.
896                    declaration = true;
897                    wrapper = Some(frame.node);
898                }
899
900                let mut lexical_scopes = frame.lexical_scopes.clone();
901                if matches!(
902                    frame.node.kind(),
903                    "class_definition" | "function_definition" | "lambda"
904                ) {
905                    lexical_scopes.push(frame.node);
906                }
907                children.push(PythonFrame {
908                    node: child,
909                    scope: child_scope,
910                    module_control_depth: child_depth,
911                    declaration,
912                    wrapper,
913                    lexical_scopes,
914                });
915            }
916
917            stack.push(PythonWork::Exit);
918            stack.extend(children.into_iter().rev().map(PythonWork::Enter));
919        }
920    }
921
922    fn admit_structural(&mut self, node: Node<'_>, parent: Option<u32>) -> Option<u32> {
923        if !node.is_named() {
924            return parent;
925        }
926        let Some(raw_kind) = self.structural_kinds.kind_of(&node) else {
927            return parent;
928        };
929        if !PYTHON_STRUCTURAL_SPEC.should_extract(node, raw_kind) {
930            return parent;
931        }
932        let kind = PYTHON_STRUCTURAL_SPEC.refine_kind(
933            node,
934            raw_kind,
935            parent.map(|id| self.structural.normalized_kind(id)),
936            self.source,
937            self.call_site_context,
938        );
939        let fact_id = self
940            .structural
941            .enter(node, kind, parent, &mut self.source_facts)
942            .expect("Python structural fact collection must remain unbounded");
943        let mut sink = self.structural.role_sink(&mut self.source_facts);
944        PYTHON_STRUCTURAL_SPEC.extract(node, kind, &mut sink);
945        self.structural
946            .accept_roles(fact_id, sink.into_parts())
947            .expect("Python structural role collection must remain unbounded");
948        Some(fact_id)
949    }
950
951    fn replace_source_declaration(
952        &mut self,
953        declaration_node: Node<'_>,
954        name_node: Option<Node<'_>>,
955        unit: CodeUnit,
956        metadata_ordinal: Option<usize>,
957    ) -> SourceDeclarationId {
958        self.source_declaration_units
959            .retain(|(_, existing)| existing != &unit);
960        self.parsed
961            .source_declaration_metadata
962            .retain(|link| link.unit != unit);
963        self.record_source_declaration(declaration_node, name_node, unit, metadata_ordinal)
964    }
965
966    fn record_source_declaration(
967        &mut self,
968        declaration_node: Node<'_>,
969        name_node: Option<Node<'_>>,
970        unit: CodeUnit,
971        metadata_ordinal: Option<usize>,
972    ) -> SourceDeclarationId {
973        let declaration = self.source_facts.declare_node(declaration_node, name_node);
974        self.link_source_declaration(declaration, unit, metadata_ordinal);
975        declaration
976    }
977
978    fn capture_function_return_fact<'tree>(
979        &mut self,
980        node: Node<'tree>,
981        wrapper: Option<Node<'tree>>,
982    ) {
983        let declaration_node = wrapper
984            .or_else(|| {
985                node.parent()
986                    .filter(|parent| parent.kind() == "decorated_definition")
987            })
988            .unwrap_or(node);
989        let declaration = self
990            .source_facts
991            .declare_node(declaration_node, node.child_by_field_name("name"));
992        let return_annotation = node
993            .child_by_field_name("return_type")
994            .map(|annotation| self.source_facts.intern_node(annotation));
995        let annotation_references = node
996            .child_by_field_name("return_type")
997            .map(|annotation| {
998                python_annotation_references(annotation, self.source, &mut self.source_facts)
999            })
1000            .unwrap_or_default();
1001        let runtime_type = node
1002            .child_by_field_name("return_type")
1003            .and_then(|annotation| python_return_type_identity(annotation, self.source));
1004        let fact_index = self.python_source_facts.callable_returns.len();
1005        self.python_source_facts
1006            .callable_returns
1007            .push(PythonCallableReturnFact {
1008                declaration,
1009                return_annotation,
1010                annotation_references,
1011                runtime_type,
1012            });
1013        assert!(
1014            self.python_callable_return_by_node
1015                .insert(node.id(), fact_index)
1016                .is_none(),
1017            "each Python function definition must have one callable return fact"
1018        );
1019    }
1020
1021    fn link_source_declaration(
1022        &mut self,
1023        declaration: SourceDeclarationId,
1024        unit: CodeUnit,
1025        metadata_ordinal: Option<usize>,
1026    ) {
1027        self.source_declaration_unit_keys.insert(unit.clone());
1028        self.source_declaration_units
1029            .push((declaration, unit.clone()));
1030        if let Some(metadata_ordinal) = metadata_ordinal {
1031            self.parsed
1032                .source_declaration_metadata
1033                .push(SourceDeclarationMetadataLink {
1034                    declaration,
1035                    unit,
1036                    metadata_ordinal,
1037                });
1038        }
1039    }
1040
1041    fn visit_statement<'tree>(
1042        &mut self,
1043        node: Node<'tree>,
1044        scope: &[Scope],
1045        module_control_depth: usize,
1046        wrapper: Option<Node<'tree>>,
1047    ) -> DeclarationChildren<'tree> {
1048        match node.kind() {
1049            "decorated_definition" => DeclarationChildren {
1050                body: None,
1051                container_scope: None,
1052                container_depth: module_control_depth,
1053            },
1054            "class_definition" | "function_definition" => {
1055                self.visit_definition(node, wrapper, scope, module_control_depth)
1056            }
1057            "expression_statement" => {
1058                self.visit_expression_statement(node, scope, module_control_depth);
1059                DeclarationChildren {
1060                    body: None,
1061                    container_scope: None,
1062                    container_depth: module_control_depth,
1063                }
1064            }
1065            "import_statement" | "import_from_statement" => DeclarationChildren {
1066                body: None,
1067                container_scope: None,
1068                container_depth: module_control_depth,
1069            },
1070            "if_statement" | "try_statement" | "with_statement" | "for_statement"
1071            | "while_statement" => {
1072                let next_depth = if scope.is_empty() {
1073                    module_control_depth + 1
1074                } else {
1075                    module_control_depth
1076                };
1077                DeclarationChildren {
1078                    body: None,
1079                    container_scope: Some(scope.to_vec()),
1080                    container_depth: next_depth,
1081                }
1082            }
1083            "elif_clause" | "else_clause" | "except_clause" | "finally_clause" => {
1084                DeclarationChildren {
1085                    body: None,
1086                    container_scope: Some(scope.to_vec()),
1087                    container_depth: module_control_depth,
1088                }
1089            }
1090            "block" | "module" => DeclarationChildren {
1091                body: None,
1092                container_scope: Some(scope.to_vec()),
1093                container_depth: module_control_depth,
1094            },
1095            _ => DeclarationChildren {
1096                body: None,
1097                container_scope: None,
1098                container_depth: module_control_depth,
1099            },
1100        }
1101    }
1102
1103    fn visit_definition<'tree>(
1104        &mut self,
1105        definition: Node<'tree>,
1106        wrapper: Option<Node<'tree>>,
1107        scope: &[Scope],
1108        module_control_depth: usize,
1109    ) -> DeclarationChildren<'tree> {
1110        match definition.kind() {
1111            "class_definition" => self.visit_class_definition(
1112                definition,
1113                wrapper.unwrap_or(definition),
1114                scope,
1115                module_control_depth,
1116            ),
1117            "function_definition" => self.visit_function_definition(
1118                definition,
1119                wrapper.unwrap_or(definition),
1120                scope,
1121                module_control_depth,
1122            ),
1123            _ => DeclarationChildren {
1124                body: None,
1125                container_scope: None,
1126                container_depth: module_control_depth,
1127            },
1128        }
1129    }
1130
1131    fn visit_class_definition<'tree>(
1132        &mut self,
1133        node: Node<'tree>,
1134        range_node: Node<'tree>,
1135        scope: &[Scope],
1136        module_control_depth: usize,
1137    ) -> DeclarationChildren<'tree> {
1138        let Some(name_node) = node.child_by_field_name("name") else {
1139            return DeclarationChildren {
1140                body: None,
1141                container_scope: None,
1142                container_depth: module_control_depth,
1143            };
1144        };
1145        let name = py_node_text(name_node, self.source).trim();
1146        if name.is_empty() {
1147            return DeclarationChildren {
1148                body: None,
1149                container_scope: None,
1150                container_depth: module_control_depth,
1151            };
1152        }
1153
1154        let capture = !scope.is_empty() || module_control_depth <= 1;
1155
1156        let short_name = scope
1157            .last()
1158            .map(|parent| format!("{}${name}", parent.path))
1159            .unwrap_or_else(|| name.to_string());
1160        // A nested class (any parent scope, Class or Function) is always joined
1161        // with a literal `$` in the legacy convention above, which is exactly
1162        // what `SegmentKind::Nested` renders regardless of the preceding
1163        // segment's kind; a top-level class has no parent and is a plain `Type`
1164        // hanging off the module-path `Package` chain.
1165        let fq = match scope.last() {
1166            Some(parent) => parent
1167                .fq
1168                .clone()
1169                .with_pushed(segment_interner().intern(name, SegmentKind::Nested)),
1170            None => self
1171                .module_fq
1172                .clone()
1173                .with_pushed(segment_interner().intern(name, SegmentKind::Type)),
1174        };
1175        let code_unit = CodeUnit::new_fq(
1176            self.file.clone(),
1177            CodeUnitType::Class,
1178            self.package_name.to_string(),
1179            short_name.clone(),
1180            fq.clone(),
1181        );
1182        if capture {
1183            self.parsed
1184                .replace_code_unit(code_unit.clone(), range_node, self.source, None, None);
1185            self.parsed.add_signature(
1186                code_unit.clone(),
1187                python_class_signature(range_node, self.source),
1188            );
1189            if let Some(module) = &self.module
1190                && scope.is_empty()
1191            {
1192                self.parsed.add_child(module.clone(), code_unit.clone());
1193            }
1194            if let Some(parent) = scope.last()
1195                && let Some(parent_cu) = &parent.code_unit
1196            {
1197                self.parsed.add_child(parent_cu.clone(), code_unit.clone());
1198            }
1199            self.parsed.set_raw_supertypes(
1200                code_unit.clone(),
1201                extract_python_supertypes(node, self.source),
1202            );
1203            self.replace_source_declaration(range_node, Some(name_node), code_unit.clone(), None);
1204        }
1205
1206        let mut next_scope = scope.to_vec();
1207        if capture {
1208            next_scope.push(Scope {
1209                kind: ScopeKind::Class,
1210                path: short_name,
1211                fq,
1212                code_unit: Some(code_unit),
1213                method_receiver: None,
1214            });
1215        }
1216        DeclarationChildren {
1217            body: node
1218                .child_by_field_name("body")
1219                .map(|body| (body, next_scope)),
1220            container_scope: None,
1221            container_depth: module_control_depth,
1222        }
1223    }
1224
1225    fn visit_function_definition<'tree>(
1226        &mut self,
1227        node: Node<'tree>,
1228        range_node: Node<'tree>,
1229        scope: &[Scope],
1230        module_control_depth: usize,
1231    ) -> DeclarationChildren<'tree> {
1232        let Some(name_node) = node.child_by_field_name("name") else {
1233            return DeclarationChildren {
1234                body: None,
1235                container_scope: None,
1236                container_depth: module_control_depth,
1237            };
1238        };
1239        let name = py_node_text(name_node, self.source).trim();
1240        if name.is_empty() {
1241            return DeclarationChildren {
1242                body: None,
1243                container_scope: None,
1244                container_depth: module_control_depth,
1245            };
1246        }
1247
1248        // Only the shapes a reviewed summary can name are declarations: a
1249        // function written at module level (at most one control level deep, so
1250        // a `def` under `if TYPE_CHECKING:` still counts) or directly in a
1251        // class body. A function nested inside another function's body is a
1252        // local binding of its enclosing function, not a member of any owner,
1253        // so it mints no declaration and no procedure key; `@x.setter` is a
1254        // write into its class's attribute rather than a callable member.
1255        let capture = !python_is_property_mutator(range_node, self.source)
1256            && ((scope.is_empty() && module_control_depth <= 1)
1257                || scope
1258                    .last()
1259                    .is_some_and(|parent| parent.kind == ScopeKind::Class));
1260        let short_name = if let Some(parent) = scope.last() {
1261            match parent.kind {
1262                ScopeKind::Class => format!("{}.{}", parent.path, name),
1263                ScopeKind::Function => format!("{}${name}", parent.path),
1264            }
1265        } else {
1266            name.to_string()
1267        };
1268        // Mirrors `short_name` above segment-for-segment: a method owned
1269        // directly by a class joins with `.` (`Member`), while a function
1270        // nested under another function is a local/closure and joins with the
1271        // literal `$` that `SegmentKind::Nested` renders.
1272        let fq = if let Some(parent) = scope.last() {
1273            match parent.kind {
1274                ScopeKind::Class => parent
1275                    .fq
1276                    .clone()
1277                    .with_pushed(segment_interner().intern(name, SegmentKind::Member)),
1278                ScopeKind::Function => parent
1279                    .fq
1280                    .clone()
1281                    .with_pushed(segment_interner().intern(name, SegmentKind::Nested)),
1282            }
1283        } else {
1284            self.module_fq
1285                .clone()
1286                .with_pushed(segment_interner().intern(name, SegmentKind::Member))
1287        };
1288
1289        if capture {
1290            let code_unit_type = if python_function_has_property_decorator(node, self.source) {
1291                CodeUnitType::Field
1292            } else {
1293                CodeUnitType::Function
1294            };
1295            let signature = node
1296                .child_by_field_name("parameters")
1297                .map(|parameters| py_node_text(parameters, self.source).trim().to_string());
1298            let code_unit = CodeUnit::with_signature_and_fq(
1299                self.file.clone(),
1300                code_unit_type,
1301                self.package_name.to_string(),
1302                short_name.clone(),
1303                signature,
1304                false,
1305                fq.clone(),
1306            );
1307            // Only a redefinition can invalidate earlier declaration links.
1308            // Scanning every prior link for each fresh function is quadratic.
1309            let replaces_declaration = self.parsed.contains_declaration(&code_unit);
1310            let replaces_links = self.source_declaration_unit_keys.contains(&code_unit);
1311            if replaces_declaration {
1312                self.parsed.replace_code_unit(
1313                    code_unit.clone(),
1314                    range_node,
1315                    self.source,
1316                    None,
1317                    None,
1318                );
1319            } else {
1320                self.parsed
1321                    .add_code_unit(code_unit.clone(), range_node, self.source, None, None);
1322            }
1323            let signature = python_function_signature(node, self.source);
1324            let fact_index = *self
1325                .python_callable_return_by_node
1326                .get(&node.id())
1327                .expect("function return fact must precede declaration admission");
1328            let return_fact = &self.python_source_facts.callable_returns[fact_index];
1329            let return_declaration = return_fact.declaration;
1330            let return_type_identity = return_fact.runtime_type.clone();
1331            let return_type_text = return_fact.return_annotation.map(|annotation| {
1332                let range = self.source_facts.occurrence(annotation).range;
1333                self.source[range.start_byte..range.end_byte].to_string()
1334            });
1335            let metadata_ordinal = self.parsed.add_signature_with_metadata(
1336                code_unit.clone(),
1337                python_signature_metadata(
1338                    signature,
1339                    node,
1340                    self.source,
1341                    return_type_text.as_deref(),
1342                    return_type_identity,
1343                ),
1344            );
1345            if replaces_links {
1346                self.overload_metadata
1347                    .retain(|(existing, _, _)| existing != &code_unit);
1348                self.source_declaration_units
1349                    .retain(|(_, existing)| existing != &code_unit);
1350                self.parsed
1351                    .source_declaration_metadata
1352                    .retain(|link| link.unit != code_unit);
1353            }
1354            self.overload_metadata.push((
1355                code_unit.clone(),
1356                metadata_ordinal,
1357                PythonOverloadDecoratorBindings::overload_decorator_names(node, self.source),
1358            ));
1359            self.link_source_declaration(
1360                return_declaration,
1361                code_unit.clone(),
1362                Some(metadata_ordinal),
1363            );
1364            if let Some(module) = &self.module
1365                && scope.is_empty()
1366            {
1367                self.parsed.add_child(module.clone(), code_unit.clone());
1368            }
1369            if let Some(parent) = scope.last()
1370                && parent.kind == ScopeKind::Class
1371                && let Some(parent_cu) = &parent.code_unit
1372            {
1373                self.parsed.add_child(parent_cu.clone(), code_unit.clone());
1374            }
1375            let scope_code_unit = Some(code_unit);
1376            let mut next_scope = scope.to_vec();
1377            next_scope.push(Scope {
1378                kind: ScopeKind::Function,
1379                path: short_name,
1380                fq,
1381                code_unit: scope_code_unit,
1382                method_receiver: scope
1383                    .last()
1384                    .is_some_and(|parent| parent.kind == ScopeKind::Class)
1385                    .then(|| python_instance_method_receiver_name(node, self.source))
1386                    .flatten(),
1387            });
1388            return DeclarationChildren {
1389                body: node
1390                    .child_by_field_name("body")
1391                    .map(|body| (body, next_scope)),
1392                container_scope: None,
1393                container_depth: module_control_depth,
1394            };
1395        }
1396
1397        let mut next_scope = scope.to_vec();
1398        next_scope.push(Scope {
1399            kind: ScopeKind::Function,
1400            path: short_name,
1401            fq,
1402            code_unit: None,
1403            method_receiver: None,
1404        });
1405        DeclarationChildren {
1406            body: node
1407                .child_by_field_name("body")
1408                .map(|body| (body, next_scope)),
1409            container_scope: None,
1410            container_depth: module_control_depth,
1411        }
1412    }
1413
1414    fn visit_expression_statement(
1415        &mut self,
1416        node: Node<'_>,
1417        scope: &[Scope],
1418        module_control_depth: usize,
1419    ) {
1420        let Some(assignment) = node.named_child(0) else {
1421            return;
1422        };
1423        if assignment.kind() != "assignment" {
1424            return;
1425        }
1426        let targets = python_chained_assignment_targets(assignment);
1427        if targets.is_empty() {
1428            return;
1429        }
1430        for left in &targets {
1431            self.visit_instance_attribute_assignment(*left, scope);
1432        }
1433        let names = targets
1434            .iter()
1435            .flat_map(|left| collect_assigned_name_nodes(*left, self.source));
1436        for name_node in names {
1437            let name = py_node_text(name_node, self.source).trim().to_string();
1438            if name.is_empty() {
1439                continue;
1440            }
1441            let (short_name, fq) = if let Some(parent) = scope.last() {
1442                if parent.kind != ScopeKind::Class {
1443                    continue;
1444                }
1445                (
1446                    format!("{}.{}", parent.path, name),
1447                    parent
1448                        .fq
1449                        .clone()
1450                        .with_pushed(segment_interner().intern(&name, SegmentKind::Member)),
1451                )
1452            } else if module_control_depth <= 1 {
1453                (
1454                    name.clone(),
1455                    self.module_fq
1456                        .clone()
1457                        .with_pushed(segment_interner().intern(&name, SegmentKind::Member)),
1458                )
1459            } else {
1460                continue;
1461            };
1462            let code_unit = CodeUnit::new_fq(
1463                self.file.clone(),
1464                CodeUnitType::Field,
1465                self.package_name.to_string(),
1466                short_name,
1467                fq,
1468            );
1469            if scope
1470                .last()
1471                .is_some_and(|parent| parent.kind == ScopeKind::Class)
1472            {
1473                // Reassigning a class attribute does not mint a new logical
1474                // member. Preserve every physical binding range so class-body
1475                // references between assignments can select the active one.
1476                self.parsed
1477                    .add_code_unit(code_unit.clone(), node, self.source, None, None);
1478            } else {
1479                self.parsed
1480                    .replace_code_unit(code_unit.clone(), node, self.source, None, None);
1481            }
1482            self.parsed.add_signature(
1483                code_unit.clone(),
1484                py_node_text(node, self.source).trim().to_string(),
1485            );
1486            if scope
1487                .last()
1488                .is_some_and(|parent| parent.kind == ScopeKind::Class)
1489            {
1490                self.record_source_declaration(node, Some(name_node), code_unit.clone(), None);
1491            } else {
1492                self.replace_source_declaration(node, Some(name_node), code_unit.clone(), None);
1493            }
1494            if let Some(module) = &self.module
1495                && scope.is_empty()
1496            {
1497                self.parsed.add_child(module.clone(), code_unit.clone());
1498            }
1499            if let Some(parent) = scope.last()
1500                && parent.kind == ScopeKind::Class
1501                && let Some(parent_cu) = &parent.code_unit
1502            {
1503                self.parsed.add_child(parent_cu.clone(), code_unit);
1504            }
1505        }
1506    }
1507
1508    fn visit_instance_attribute_assignment(&mut self, left: Node<'_>, scope: &[Scope]) {
1509        let Some(function) = scope
1510            .last()
1511            .filter(|scope| scope.kind == ScopeKind::Function)
1512        else {
1513            return;
1514        };
1515        let Some(receiver) = function.method_receiver.as_deref() else {
1516            return;
1517        };
1518        let Some(parent) = scope
1519            .get(scope.len().saturating_sub(2))
1520            .filter(|scope| scope.kind == ScopeKind::Class)
1521        else {
1522            return;
1523        };
1524        let Some(parent_cu) = parent.code_unit.clone() else {
1525            return;
1526        };
1527        for (name, node) in collect_self_assigned_attributes(left, self.source, receiver) {
1528            let code_unit = CodeUnit::new_fq(
1529                self.file.clone(),
1530                CodeUnitType::Field,
1531                self.package_name.to_string(),
1532                format!("{}.{}", parent.path, name),
1533                parent
1534                    .fq
1535                    .clone()
1536                    .with_pushed(segment_interner().intern(&name, SegmentKind::Member)),
1537            );
1538            if !self.parsed.contains_declaration(&code_unit) {
1539                self.parsed.replace_code_unit(
1540                    code_unit.clone(),
1541                    node,
1542                    self.source,
1543                    Some(parent_cu.clone()),
1544                    Some(parent_cu.clone()),
1545                );
1546                self.record_source_declaration(node, Some(node), code_unit.clone(), None);
1547            }
1548            self.parsed.add_signature(
1549                code_unit.clone(),
1550                py_node_text(node, self.source).trim().to_string(),
1551            );
1552        }
1553    }
1554
1555    fn visit_import_statement(&mut self, node: Node<'_>, lexical_scopes: &[Node<'_>]) {
1556        for syntax in python_import_syntaxes_from_node(node, self.source) {
1557            if lexical_scopes.is_empty() {
1558                self.overload_decorators.collect_import(&syntax.info);
1559            }
1560            let declaration = self.source_facts.intern_node(syntax.declaration);
1561            let target = syntax
1562                .target
1563                .map(|node| self.source_facts.intern_node(node));
1564            let alias_occurrence = syntax.alias.map(|node| self.source_facts.intern_node(node));
1565            let lexical_scopes = lexical_scopes
1566                .iter()
1567                .map(|node| self.source_facts.intern_node(*node))
1568                .collect();
1569            let source_import = SourceImportFact::from_import(
1570                syntax.info,
1571                declaration,
1572                target,
1573                alias_occurrence,
1574                lexical_scopes,
1575            );
1576            let id = SourceImportId::try_from_index(self.source_imports.len())
1577                .expect("Python source import ids must fit in a u32");
1578            self.source_imports.push(source_import);
1579            self.generic_imports.push(id);
1580        }
1581    }
1582
1583    fn finish(self, root: Node<'_>) {
1584        let PythonVisitor {
1585            parsed,
1586            source,
1587            source_facts,
1588            structural,
1589            structural_parents,
1590            source_imports,
1591            generic_imports,
1592            source_declaration_units,
1593            python_source_facts,
1594            python_callable_return_by_node: _,
1595            overload_decorators,
1596            overload_metadata,
1597            ..
1598        } = self;
1599        assert_eq!(structural_parents, vec![None]);
1600        let structural = structural
1601            .finish(root)
1602            .expect("Python structural fact collection must finish");
1603        let occurrences = source_facts.finish();
1604        assert!(
1605            python_source_facts.valid_links(&occurrences),
1606            "Python callable return facts must link to exact source identities"
1607        );
1608        for (unit, metadata_ordinal, decorators) in overload_metadata {
1609            let declaration_only = decorators
1610                .iter()
1611                .any(|name| overload_decorators.matches_overload_binding(name));
1612            if declaration_only
1613                && let Some(metadata) = parsed
1614                    .signature_metadata
1615                    .get_mut(&unit)
1616                    .and_then(|entries| entries.get_mut(metadata_ordinal))
1617            {
1618                *metadata = metadata.clone().with_declaration_only(true);
1619            }
1620        }
1621        parsed.imports = generic_imports
1622            .iter()
1623            .map(|id| source_imports[id.index()].import_info(&occurrences))
1624            .collect();
1625        parsed.source_declaration_units = source_declaration_units;
1626        parsed.source_facts = Some(ParsedSourceFacts {
1627            cpp: None,
1628            go: None,
1629            java: None,
1630            js_ts: None,
1631            php: None,
1632            scala: None,
1633            ruby: None,
1634            source_bytes: source.len(),
1635            occurrences,
1636            structural,
1637            python: Some(python_source_facts),
1638            native_site_occurrences: Vec::new(),
1639            native_declaration_sources: Vec::new(),
1640            declaration_visibilities: None,
1641            rust_declaration_properties: Vec::new(),
1642            rust_modules: None,
1643            rust_types: Vec::new(),
1644            rust_items: RustItemSourceFacts::default(),
1645            imports: source_imports,
1646            generic_imports,
1647            rust_import_contexts: Vec::new(),
1648        });
1649    }
1650}
1651
1652/// Build the [`ParsedFile`] for one Python source file: module unit, type
1653/// identifiers, and the declaration walk. `analyzer/python/adapter.rs`'s
1654/// `LanguageAdapter::parse_file` is the only caller.
1655pub fn parse_python_file(
1656    project: &dyn Project,
1657    file: &ProjectFile,
1658    source: &str,
1659    tree: &Tree,
1660) -> ParsedFile {
1661    let module_components = python_module_components(project, file);
1662    let module_name = module_components.join(".");
1663    let module_fq = FqName::from_package_components(&module_components);
1664    let mut parsed = ParsedFile::new(module_name.clone());
1665    let root = tree.root_node();
1666
1667    let module_code_unit = module_code_unit_from_fq(file, &module_components, module_fq.clone());
1668    if let Some(module) = module_code_unit.clone() {
1669        parsed.add_code_unit(module, root, source, None, None);
1670    }
1671
1672    let overload_decorators = PythonOverloadDecoratorBindings::default();
1673    let structural_kinds = CompiledKinds::compile(
1674        &tree_sitter_python::LANGUAGE.into(),
1675        PYTHON_STRUCTURAL_SPEC.kind_table(),
1676    );
1677    let call_site_context = PYTHON_STRUCTURAL_SPEC.call_site_context(root, source);
1678    let mut visitor = PythonVisitor {
1679        file,
1680        source,
1681        package_name: &module_name,
1682        module_fq: &module_fq,
1683        parsed: &mut parsed,
1684        module: module_code_unit,
1685        overload_decorators,
1686        source_facts: PrimarySourceFactCollector::new(source),
1687        structural: StructuralFactCollector::new(
1688            &PYTHON_STRUCTURAL_SPEC,
1689            source,
1690            &call_site_context,
1691            ParentIndex::new(root),
1692            usize::MAX,
1693            None,
1694        ),
1695        structural_kinds: &structural_kinds,
1696        call_site_context: &call_site_context,
1697        structural_parents: vec![None],
1698        source_imports: Vec::new(),
1699        generic_imports: Vec::new(),
1700        source_declaration_units: Vec::new(),
1701        source_declaration_unit_keys: HashSet::default(),
1702        python_source_facts: PythonSourceFacts::default(),
1703        python_callable_return_by_node: HashMap::default(),
1704        overload_metadata: Vec::new(),
1705    };
1706    visitor.visit_container(root, &[], 0);
1707    visitor.finish(root);
1708
1709    parsed
1710}
1711
1712pub use brokk_bifrost_core::analyzer::common::node_source_text as py_node_text;
1713
1714pub fn python_module_name(project: &dyn Project, file: &ProjectFile) -> String {
1715    python_module_components(project, file).join(".")
1716}
1717
1718pub fn module_code_unit(project: &dyn Project, file: &ProjectFile) -> Option<CodeUnit> {
1719    let components = python_module_components(project, file);
1720    let structured_fq = FqName::from_package_components(&components);
1721    module_code_unit_from_fq(file, &components, structured_fq)
1722}
1723
1724fn module_code_unit_from_fq(
1725    file: &ProjectFile,
1726    components: &[String],
1727    structured_fq: FqName,
1728) -> Option<CodeUnit> {
1729    let (short_name, package_components) = components.split_last()?;
1730    let package_name = package_components.join(".");
1731    Some(CodeUnit::new_fq(
1732        file.clone(),
1733        CodeUnitType::Module,
1734        package_name,
1735        short_name.clone(),
1736        structured_fq,
1737    ))
1738}
1739
1740fn python_class_signature(node: Node<'_>, source: &str) -> String {
1741    python_header_with_decorators(node, source)
1742}
1743
1744fn python_function_signature(function: Node<'_>, source: &str) -> String {
1745    let header_end = (0..function.child_count())
1746        .rev()
1747        .filter_map(|index| function.child(index))
1748        .find(|child| child.kind() == ":")
1749        .map_or_else(
1750            || {
1751                function
1752                    .child_by_field_name("body")
1753                    .map_or(function.end_byte(), |body| body.start_byte())
1754            },
1755            |colon| colon.end_byte(),
1756        );
1757    let header_node = python_function_header_node(function);
1758    let header_start = header_node.start_byte();
1759    let header = source
1760        .get(header_start..header_end)
1761        .expect("Python function header must be a source range")
1762        .trim_end();
1763    format!(
1764        "{} ...",
1765        python_dedent_function_header(header, header_node.start_position().column)
1766    )
1767}
1768
1769fn python_dedent_function_header(header: &str, base_indent: usize) -> String {
1770    let mut lines = header.lines();
1771    let Some(first) = lines.next() else {
1772        return String::new();
1773    };
1774    let mut dedented = String::with_capacity(header.len());
1775    dedented.push_str(first);
1776    for line in lines {
1777        dedented.push('\n');
1778        dedented.push_str(&line[python_header_indent_bytes(line, base_indent)..]);
1779    }
1780    dedented
1781}
1782
1783/// Map an AST byte position in a source prefix to the matching offset after
1784/// function-header dedenting and CRLF normalization.
1785fn python_dedented_header_offset(source_prefix: &str, base_indent: usize) -> usize {
1786    let mut removed_bytes = 0;
1787    let mut line_start = 0;
1788    let mut continuation_line = false;
1789
1790    while line_start < source_prefix.len() {
1791        let newline = source_prefix.as_bytes()[line_start..]
1792            .iter()
1793            .position(|byte| *byte == b'\n')
1794            .map(|offset| line_start + offset);
1795        let line_end = newline.unwrap_or(source_prefix.len());
1796        let line = &source_prefix[line_start..line_end];
1797        if continuation_line {
1798            removed_bytes += python_header_indent_bytes(line, base_indent);
1799        }
1800        let Some(newline) = newline else {
1801            break;
1802        };
1803        if newline > 0 && source_prefix.as_bytes()[newline - 1] == b'\r' {
1804            removed_bytes += 1;
1805        }
1806        line_start = newline + 1;
1807        continuation_line = true;
1808    }
1809
1810    source_prefix.len() - removed_bytes
1811}
1812
1813fn python_header_indent_bytes(line: &str, base_indent: usize) -> usize {
1814    line.bytes()
1815        .take_while(|byte| matches!(*byte, b' ' | b'\t'))
1816        .count()
1817        .min(base_indent)
1818}
1819
1820fn python_signature_metadata(
1821    signature: String,
1822    node: Node<'_>,
1823    source: &str,
1824    return_type_text: Option<&str>,
1825    return_type_identity: Option<StructuredTypeIdentity>,
1826) -> SignatureMetadata {
1827    let Some(parameters_node) = node.child_by_field_name("parameters") else {
1828        return SignatureMetadata::new(signature, Vec::new())
1829            .with_return_type_text(return_type_text)
1830            .with_return_type_identity(return_type_identity)
1831            .with_dispatch_extensibility(DispatchExtensibility::Open)
1832            .with_callable_modifiers(
1833                python_callable_is_static(node, source),
1834                false,
1835                DeclaredVisibility::Unknown,
1836            );
1837    };
1838    let arity = python_callable_arity(parameters_node, source);
1839    // Parameter labels use offsets from their exact AST nodes, so repeated
1840    // spellings in annotations/defaults cannot masquerade as another
1841    // parameter's identity. The full source header, including multiline
1842    // continuations and decorators, is retained in the signature label.
1843    let header_node = python_function_header_node(node);
1844    let header_start = header_node.start_byte();
1845    let base_indent = header_node.start_position().column;
1846    let parameters = python_parameter_label_nodes(parameters_node)
1847        .into_iter()
1848        .map(|label_node| {
1849            let label = py_node_text(label_node, source);
1850            let label_range = label_node.byte_range();
1851            let source_prefix = source
1852                .get(header_start..label_range.start)
1853                .expect("Python parameter must follow its function header");
1854            let start_byte = python_dedented_header_offset(source_prefix, base_indent);
1855            let end_byte = start_byte + label.len();
1856            assert_eq!(
1857                signature.get(start_byte..end_byte),
1858                Some(label),
1859                "Python parameter metadata must point at its exact signature label"
1860            );
1861            ParameterMetadata::new(label, start_byte, end_byte)
1862        })
1863        .collect();
1864    let metadata = SignatureMetadata::new(signature, parameters)
1865        .with_return_type_text(return_type_text)
1866        .with_return_type_identity(return_type_identity)
1867        .with_dispatch_extensibility(DispatchExtensibility::Open)
1868        .with_callable_modifiers(
1869            python_callable_is_static(node, source),
1870            false,
1871            DeclaredVisibility::Unknown,
1872        );
1873    if let Some(arity) = arity {
1874        metadata.with_callable_arity(arity)
1875    } else {
1876        metadata
1877    }
1878}
1879
1880/// Positional arity of one Python parameter list, derived from its structured
1881/// parameter nodes. Keyword-only parameters are intentionally excluded from
1882/// positional counts; `*args` makes the upper bound open, while `**kwargs`
1883/// does not accept extra positional values.
1884fn python_callable_arity(parameters: Node<'_>, source: &str) -> Option<CallableArity> {
1885    let mut required = 0;
1886    let mut total = 0;
1887    let mut repeated = false;
1888    let mut keyword_only = false;
1889    let mut cursor = parameters.walk();
1890    for parameter in parameters.named_children(&mut cursor) {
1891        match parameter.kind() {
1892            "positional_separator" => continue,
1893            "keyword_separator" => {
1894                keyword_only = true;
1895                continue;
1896            }
1897            "ERROR" => return None,
1898            _ => {}
1899        }
1900        if !matches!(
1901            parameter.kind(),
1902            "identifier"
1903                | "typed_parameter"
1904                | "default_parameter"
1905                | "typed_default_parameter"
1906                | "list_splat_pattern"
1907                | "dictionary_splat_pattern"
1908        ) {
1909            continue;
1910        }
1911        let label = python_parameter_label_node(parameter)?;
1912        source.get(label.byte_range())?;
1913        match python_parameter_splat(parameter) {
1914            Some(PythonParameterSplat::Positional) => {
1915                repeated = true;
1916                keyword_only = true;
1917            }
1918            Some(PythonParameterSplat::Keyword) => {}
1919            None if !keyword_only => {
1920                total += 1;
1921                if !matches!(
1922                    parameter.kind(),
1923                    "default_parameter" | "typed_default_parameter"
1924                ) {
1925                    required += 1;
1926                }
1927            }
1928            None => {}
1929        }
1930    }
1931    Some(CallableArity::new(required, total, repeated))
1932}
1933
1934fn python_function_header_node(node: Node<'_>) -> Node<'_> {
1935    node.parent()
1936        .filter(|parent| parent.kind() == "decorated_definition")
1937        .unwrap_or(node)
1938}
1939
1940/// Whether this Python callable binds no instance receiver, read from its own
1941/// declaration nodes (#3451).
1942///
1943/// Recording the fact is what makes a Python declaration keyable for
1944/// procedure-summary binding: `receiver_contract_of` refuses to answer for a
1945/// callable whose adapter never inspected modifiers, so before this every
1946/// Python workspace callee contributed `callee_unkeyable` and no Python effect
1947/// coverage could be exhaustive.
1948///
1949/// Only a callable written in a class body can bind an instance receiver, and
1950/// the two decorators that remove it are `@staticmethod` and `@classmethod`.
1951/// The decorator check is the language's own
1952/// [`python_function_has_decorator`], read through the `decorated_definition`
1953/// node that owns the decorators, so the receiver contract agrees with the
1954/// receiver [`python_instance_method_receiver_name`] already resolves. A
1955/// module-level function, and a function nested in another function's body, has
1956/// no type owner at all, so its contract falls out of the owner rather than out
1957/// of this flag; it is not static either.
1958///
1959/// Python spells no constructor modifier for `__init__`: calling the class
1960/// reaches an ordinary instance method, exactly as Ruby's `initialize`, so the
1961/// constructor flag stays false and its receiver contract is `Instance`.
1962/// Visibility is a naming convention (`_name`), not a declaration node, so it
1963/// stays `Unknown`.
1964fn python_callable_is_static(node: Node<'_>, source: &str) -> bool {
1965    debug_assert!(
1966        node.kind() == "function_definition",
1967        "Python callable signature metadata is built for function_definition, not {}",
1968        node.kind()
1969    );
1970    python_function_has_decorator(node, source, "staticmethod")
1971        || python_function_has_decorator(node, source, "classmethod")
1972}
1973
1974fn python_parameter_label_nodes(parameters_node: Node<'_>) -> Vec<Node<'_>> {
1975    brokk_bifrost_core::analyzer::tree_walk::named_children_iter(parameters_node)
1976        .filter_map(python_parameter_label_node)
1977        .collect()
1978}
1979
1980/// The identifier node that names one parameter's binding.
1981///
1982/// The grammar gives `default_parameter` and `typed_default_parameter` a
1983/// `name` field but gives `typed_parameter` and the two splat patterns none,
1984/// so a caller that reads only the field loses the binding name of every
1985/// annotated parameter. Every Python surface that names parameters reads them
1986/// through this function.
1987/// Which splat a Python formal parameter spells, looking through the
1988/// annotation wrapper.
1989///
1990/// `*args` is a `list_splat_pattern` and `**kwargs` a
1991/// `dictionary_splat_pattern`, but the grammar spells `*args: str` as a
1992/// `typed_parameter` that holds one, so a test on the parameter's own node kind
1993/// misses every annotated variadic. A parameter that misses it binds like an
1994/// ordinary formal: one positional actual each, and the rest spill onto the
1995/// formals that follow.
1996#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1997pub enum PythonParameterSplat {
1998    /// `*args`: collects every remaining positional actual.
1999    Positional,
2000    /// `**kwargs`: collects every remaining keyword actual.
2001    Keyword,
2002}
2003
2004pub fn python_parameter_splat(parameter: Node<'_>) -> Option<PythonParameterSplat> {
2005    let splat = match parameter.kind() {
2006        kind @ ("list_splat_pattern" | "dictionary_splat_pattern") => kind,
2007        _ => {
2008            let mut cursor = parameter.walk();
2009            parameter
2010                .named_children(&mut cursor)
2011                .map(|child| child.kind())
2012                .find(|kind| matches!(*kind, "list_splat_pattern" | "dictionary_splat_pattern"))?
2013        }
2014    };
2015    match splat {
2016        "list_splat_pattern" => Some(PythonParameterSplat::Positional),
2017        "dictionary_splat_pattern" => Some(PythonParameterSplat::Keyword),
2018        _ => unreachable!("the splat kind was matched above"),
2019    }
2020}
2021
2022pub fn python_parameter_label_node(node: Node<'_>) -> Option<Node<'_>> {
2023    match node.kind() {
2024        "identifier" => Some(node),
2025        "typed_parameter"
2026        | "typed_default_parameter"
2027        | "default_parameter"
2028        | "list_splat_pattern"
2029        | "dictionary_splat_pattern"
2030        | "keyword_separator" => node.child_by_field_name("name").or_else(|| {
2031            let mut cursor = node.walk();
2032            node.named_children(&mut cursor)
2033                .find_map(python_parameter_label_node)
2034        }),
2035        _ => None,
2036    }
2037}
2038
2039fn python_is_property_mutator(node: Node<'_>, source: &str) -> bool {
2040    python_header_with_decorators(node, source)
2041        .lines()
2042        .map(str::trim)
2043        .filter(|line| line.starts_with('@'))
2044        .any(|decorator| decorator.ends_with(".setter") || decorator.ends_with(".deleter"))
2045}
2046
2047pub fn python_expanded_comment_start(source: &str, start_byte: usize) -> usize {
2048    let line_starts = compute_line_starts(source);
2049    let line_index = find_line_index_for_offset(&line_starts, start_byte);
2050
2051    let mut comment_start = start_byte;
2052    for line_idx in (0..line_index).rev() {
2053        let line_start = line_starts[line_idx];
2054        let line_end = line_starts
2055            .get(line_idx + 1)
2056            .copied()
2057            .unwrap_or(source.len());
2058        let line = &source[line_start..line_end];
2059        let trimmed = line.trim_start();
2060
2061        if trimmed.trim().is_empty() {
2062            continue;
2063        }
2064
2065        if trimmed.starts_with('#') {
2066            comment_start = line_start;
2067            continue;
2068        }
2069
2070        break;
2071    }
2072
2073    comment_start
2074}
2075
2076fn python_header_with_decorators(node: Node<'_>, source: &str) -> String {
2077    let raw = py_node_text(node, source);
2078    let lines: Vec<_> = raw
2079        .lines()
2080        .map(str::trim_end)
2081        .filter(|line| !line.trim().is_empty())
2082        .collect();
2083    let mut relevant = Vec::new();
2084    for line in lines {
2085        let trimmed = line.trim_start();
2086        if trimmed.starts_with('@')
2087            || trimmed.starts_with("def ")
2088            || trimmed.starts_with("async def ")
2089            || trimmed.starts_with("class ")
2090        {
2091            relevant.push(trimmed.to_string());
2092            if trimmed.starts_with("def ")
2093                || trimmed.starts_with("async def ")
2094                || trimmed.starts_with("class ")
2095            {
2096                break;
2097            }
2098        }
2099    }
2100    relevant.join("\n")
2101}
2102
2103/// Every positional base of a class, as the spelling the hierarchy resolver
2104/// should look up.
2105///
2106/// A base this function omits is indistinguishable from a class that has no
2107/// such base, so member lookup would treat an incompletely modeled hierarchy
2108/// as a complete one and prove a member absent that the base declares. Every
2109/// positional base therefore contributes a spelling:
2110///
2111/// * A dotted name is its own spelling.
2112/// * A subscripted base (`Base[T]`, `MutableMapping[str, Any]`) contributes
2113///   its generic origin, which is the class the runtime actually inherits.
2114/// * Any other positional base -- a call such as `namedtuple(...)`, an
2115///   unpacked base list, a conditional expression -- contributes its source
2116///   spelling. Resolution fails on it, and the caller reports an unresolved
2117///   base instead of a complete member list.
2118///
2119/// A keyword argument (`metaclass=`, and the arbitrary keywords
2120/// `__init_subclass__` accepts) is not a base and contributes nothing here.
2121fn extract_python_supertypes(node: Node<'_>, source: &str) -> Vec<String> {
2122    let Some(superclasses) = node.child_by_field_name("superclasses") else {
2123        return Vec::new();
2124    };
2125    let mut result = Vec::new();
2126    let mut cursor = superclasses.walk();
2127    for child in superclasses.named_children(&mut cursor) {
2128        if child.kind() == "keyword_argument" {
2129            continue;
2130        }
2131        let named = python_base_origin_node(child);
2132        let text = py_node_text(named, source).trim();
2133        if !text.is_empty() {
2134            result.push(text.to_string());
2135        }
2136    }
2137    result
2138}
2139
2140/// The node whose text names a base class: the value a subscripted base
2141/// applies its type arguments to, or the base expression itself.
2142pub fn python_base_origin_node<'tree>(base: Node<'tree>) -> Node<'tree> {
2143    if base.kind() != "subscript" {
2144        return base;
2145    }
2146    let Some(value) = base.child_by_field_name("value") else {
2147        return base;
2148    };
2149    if matches!(value.kind(), "identifier" | "attribute") {
2150        value
2151    } else {
2152        base
2153    }
2154}
2155
2156fn collect_assigned_name_nodes<'tree>(node: Node<'tree>, _source: &str) -> Vec<Node<'tree>> {
2157    let mut names = Vec::new();
2158    walk_named_tree_preorder(node, true, |node| {
2159        match node.kind() {
2160            // An attribute or subscript target (`foo.bar = …`, `foo[i] = …`)
2161            // mutates an existing object; it declares neither the receiver nor
2162            // the member as a name, so do not descend into it.
2163            "attribute" | "subscript" => WalkControl::SkipChildren,
2164            "identifier" => {
2165                names.push(node);
2166                WalkControl::Continue
2167            }
2168            _ => WalkControl::Continue,
2169        }
2170    });
2171    names
2172}
2173
2174fn collect_self_assigned_attributes<'tree>(
2175    node: Node<'tree>,
2176    source: &str,
2177    receiver_name: &str,
2178) -> Vec<(String, Node<'tree>)> {
2179    let mut attributes = Vec::new();
2180    collect_direct_self_assigned_attributes(node, source, receiver_name, &mut attributes);
2181    attributes
2182}
2183
2184fn collect_direct_self_assigned_attributes<'tree>(
2185    node: Node<'tree>,
2186    source: &str,
2187    receiver_name: &str,
2188    attributes: &mut Vec<(String, Node<'tree>)>,
2189) {
2190    match node.kind() {
2191        "attribute" => {
2192            let Some(object) = node.child_by_field_name("object") else {
2193                return;
2194            };
2195            if object.kind() != "identifier" || py_node_text(object, source).trim() != receiver_name
2196            {
2197                return;
2198            }
2199            let Some(attribute) = node.child_by_field_name("attribute") else {
2200                return;
2201            };
2202            let name = py_node_text(attribute, source).trim();
2203            if !name.is_empty() {
2204                attributes.push((name.to_string(), attribute));
2205            }
2206        }
2207        // The grammar spells an unpacking target three ways: a bare comma list
2208        // is a `pattern_list`, and parentheses or brackets around it make a
2209        // `tuple_pattern` or a `list_pattern`. Omitting the bracketed forms
2210        // dropped every attribute a multi-line unpacking assigns.
2211        "pattern_list"
2212        | "tuple_pattern"
2213        | "list_pattern"
2214        | "tuple"
2215        | "list"
2216        | "parenthesized_expression" => {
2217            let mut cursor = node.walk();
2218            for child in node.named_children(&mut cursor) {
2219                collect_direct_self_assigned_attributes(child, source, receiver_name, attributes);
2220            }
2221        }
2222        _ => {}
2223    }
2224}
2225
2226fn python_instance_method_receiver_name(node: Node<'_>, source: &str) -> Option<String> {
2227    if python_function_has_decorator(node, source, "staticmethod")
2228        || python_function_has_decorator(node, source, "classmethod")
2229    {
2230        return None;
2231    }
2232    python_first_parameter_name(node, source)
2233}
2234
2235pub fn python_function_has_decorator(node: Node<'_>, source: &str, decorator_name: &str) -> bool {
2236    let Some(parent) = node.parent() else {
2237        return false;
2238    };
2239    if parent.kind() != "decorated_definition" {
2240        return false;
2241    }
2242    let mut cursor = parent.walk();
2243    parent
2244        .named_children(&mut cursor)
2245        .filter(|child| child.kind() == "decorator")
2246        .filter_map(|decorator| decorator.named_child(0))
2247        .filter_map(expression_name_node)
2248        .any(|name| py_node_text(name, source).trim() == decorator_name)
2249}
2250
2251/// Whether a function's decorator syntax makes it a Python property descriptor.
2252///
2253/// The terminal decorator name is selected from the decorator AST, so both
2254/// `@cached_property` and `@functools.cached_property` are recognized without
2255/// searching source text. Declaration indexing and type-flow use this shared
2256/// predicate so a cached property is both a present attribute and an unmodeled
2257/// descriptor read.
2258pub fn python_function_has_property_decorator(node: Node<'_>, source: &str) -> bool {
2259    python_function_has_decorator(node, source, "property")
2260        || python_function_has_decorator(node, source, "cached_property")
2261}
2262
2263/// The name a callable binds its first parameter to, which for a method is
2264/// the receiver every `self.x` and `setattr(self, ...)` in its body names.
2265pub fn python_first_parameter_name(node: Node<'_>, source: &str) -> Option<String> {
2266    let parameters = node.child_by_field_name("parameters")?;
2267    let mut cursor = parameters.walk();
2268    parameters
2269        .named_children(&mut cursor)
2270        .find_map(|child| python_parameter_name(child, source))
2271}
2272
2273pub fn collect_python_identifiers(node: Node<'_>, source: &str, identifiers: &mut HashSet<String>) {
2274    brokk_bifrost_core::analyzer::tree_walk::collect_trimmed_node_texts(
2275        node,
2276        source,
2277        identifiers,
2278        |node| node.kind() == "identifier",
2279    );
2280}
2281
2282pub fn parse_python_tree(source: &str) -> Option<Tree> {
2283    let mut parser = Parser::new();
2284    parser
2285        .set_language(&tree_sitter_python::LANGUAGE.into())
2286        .expect("failed to load python parser");
2287    parser.parse(source, None)
2288}
2289
2290#[cfg(test)]
2291mod primary_tests {
2292    use super::*;
2293    use brokk_bifrost_core::analyzer::python_facts::PythonAnnotationReferenceName;
2294
2295    fn parse(source: &str) -> ParsedFile {
2296        let file = ProjectFile::new(std::env::temp_dir(), "m4.py");
2297        let tree = parse_python_tree(source).expect("Python fixture tree");
2298        parse_python_file(
2299            &brokk_bifrost_core::analyzer::project::TestProject::new(
2300                file.root(),
2301                brokk_bifrost_core::analyzer::Language::Python,
2302            ),
2303            &file,
2304            source,
2305            &tree,
2306        )
2307    }
2308
2309    #[test]
2310    #[brokk_bifrost_test_macros::hardwon(
2311        "#3673: declarations ignored an overlay-only setuptools manifest"
2312    )]
2313    fn snapshot_root_declaration_witness() {
2314        use brokk_bifrost_core::analyzer::Language;
2315        use brokk_bifrost_core::analyzer::project::{OverlayProject, Project, TestProject};
2316        use std::sync::Arc;
2317        let root = tempfile::tempdir().unwrap();
2318        let disk = Arc::new(TestProject::new(root.path(), Language::Python));
2319        let overlay = OverlayProject::new(disk);
2320        let manifest = ProjectFile::new(root.path(), "pyproject.toml");
2321        assert!(overlay.set(
2322            manifest.abs_path(),
2323            "[tool.setuptools.packages.find]\nwhere = ['source']\n".to_string()
2324        ));
2325        let file = ProjectFile::new(root.path(), "source/pkg/model.py");
2326        assert!(overlay.set(file.abs_path(), "class Widget:\n    pass\n".to_string()));
2327        let snapshot = overlay.snapshot();
2328        assert!(snapshot.read_source(&manifest).unwrap().contains("source"));
2329        let source = snapshot.read_source(&file).unwrap();
2330        let tree = parse_python_tree(&source).unwrap();
2331        let parsed = parse_python_file(&snapshot, &file, &source, &tree);
2332        let names: Vec<_> = parsed
2333            .declarations()
2334            .iter()
2335            .map(CodeUnit::fq_name)
2336            .collect();
2337        assert!(names.contains(&"pkg.model.Widget".to_string()), "{names:?}");
2338    }
2339
2340    #[test]
2341    fn setup_py_unwrap_parenthesized_skips_comments() {
2342        let source = "VALUE = (\n    # before\n    \"value\"  # after\n)\n";
2343        let tree = parse_python_tree(source).expect("Python fixture tree");
2344        let mut pending = vec![tree.root_node()];
2345        let parenthesized = loop {
2346            let node = pending.pop().expect("parenthesized expression exists");
2347            if node.kind() == "parenthesized_expression" {
2348                break node;
2349            }
2350            let mut cursor = node.walk();
2351            pending.extend(node.named_children(&mut cursor));
2352        };
2353
2354        assert_eq!(
2355            setup_py_unwrap_parenthesized(parenthesized).kind(),
2356            "string"
2357        );
2358    }
2359
2360    #[test]
2361    #[brokk_bifrost_test_macros::hardwon(
2362        "f4495e5c7c62: replacing a duplicate Python class left stale descendant callable metadata links"
2363    )]
2364    fn replacing_a_class_withdraws_descendant_metadata_links() {
2365        let parsed = parse(
2366            "from typing import overload\nclass Replaced:\n    def obsolete(self): pass\n    @overload\n    def current(self): pass\n    class Nested:\n        def stale(self): pass\n\nclass Kept:\n    def stable(self): pass\n\nclass Replaced:\n    def current(self): pass\n",
2367        );
2368        let mut names = parsed
2369            .source_declaration_metadata
2370            .iter()
2371            .map(|link| {
2372                assert!(
2373                    parsed.signature_metadata[&link.unit]
2374                        .get(link.metadata_ordinal)
2375                        .is_some(),
2376                    "the link must name retained callable metadata: {link:?}"
2377                );
2378                link.unit.short_name().to_string()
2379            })
2380            .collect::<Vec<_>>();
2381        names.sort();
2382        assert_eq!(names, ["Kept.stable", "Replaced.current"]);
2383        let current = parsed
2384            .source_declaration_metadata
2385            .iter()
2386            .find(|link| link.unit.short_name() == "Replaced.current")
2387            .unwrap();
2388        assert!(
2389            !parsed.signature_metadata[&current.unit][current.metadata_ordinal]
2390                .is_declaration_only(),
2391            "a withdrawn overload must not mark the new implementation"
2392        );
2393        assert_eq!(
2394            parsed
2395                .source_declaration_units
2396                .iter()
2397                .filter(|(_, unit)| unit.short_name() == "Replaced.current")
2398                .count(),
2399            1,
2400            "the new method replaces even an already-withdrawn source link"
2401        );
2402    }
2403
2404    #[test]
2405    fn canonical_declarations_share_structural_name_identity_and_exact_ranges() {
2406        let source = "@decorate\nclass Item:\n    value = 1\n    value = 2\n    def write(self):\n        self.member = 1\n        self.member = 2\n\nfirst, second = 1, 2\n";
2407        let parsed = parse(source);
2408        let facts = parsed
2409            .source_facts
2410            .as_ref()
2411            .expect("canonical Python facts");
2412        for (id, unit) in &parsed.source_declaration_units {
2413            let declaration = facts.occurrences.declaration(*id);
2414            let range = facts.occurrences.occurrence(declaration.occurrence).range;
2415            assert!(parsed.ranges[unit].contains(&range), "{unit:?}: {range:?}");
2416            let name = declaration.name.expect("source declaration name");
2417            assert!(
2418                facts
2419                    .structural
2420                    .nodes()
2421                    .iter()
2422                    .any(|node| node.occurrence == name),
2423                "shared name identity for {unit:?}"
2424            );
2425        }
2426        let fields = parsed
2427            .source_declaration_units
2428            .iter()
2429            .filter(|(_, unit)| unit.short_name() == "Item.value")
2430            .collect::<Vec<_>>();
2431        assert_eq!(fields.len(), 2);
2432        assert_ne!(fields[0].0, fields[1].0);
2433        assert_eq!(
2434            parsed
2435                .source_declaration_units
2436                .iter()
2437                .filter(|(_, unit)| unit.short_name() == "Item.member")
2438                .count(),
2439            1
2440        );
2441        let class = parsed
2442            .source_declaration_units
2443            .iter()
2444            .find(|(_, unit)| unit.short_name() == "Item")
2445            .expect("decorated class");
2446        let range = facts
2447            .occurrences
2448            .occurrence(facts.occurrences.declaration(class.0).occurrence)
2449            .range;
2450        assert_eq!(range.start_byte, 0);
2451        assert_eq!(&source[range.start_byte..range.start_byte + 9], "@decorate");
2452    }
2453
2454    #[test]
2455    fn imports_keep_scope_order_and_late_overload_capture_without_local_pollution() {
2456        let source = "@overload\ndef public(value: int):\n    pass\nfrom typing import overload\n\ndef outer():\n    from typing import overload as local_only\n@local_only\ndef ordinary(value: int):\n    pass\nmatch 1:\n    case 1:\n        from .models import Item as Imported\n";
2457        let parsed = parse(source);
2458        let facts = parsed.source_facts.as_ref().expect("canonical imports");
2459        assert_eq!(facts.imports.len(), 3);
2460        assert!(
2461            facts.imports[0]
2462                .path
2463                .as_ref()
2464                .unwrap()
2465                .lexical_scopes
2466                .is_empty()
2467        );
2468        assert_eq!(
2469            facts.imports[1].path.as_ref().unwrap().lexical_scopes.len(),
2470            1
2471        );
2472        assert!(
2473            facts.imports[2]
2474                .path
2475                .as_ref()
2476                .unwrap()
2477                .lexical_scopes
2478                .is_empty()
2479        );
2480        for (id, import) in facts.generic_imports.iter().zip(&parsed.imports) {
2481            assert_eq!(
2482                &facts.imports[id.index()].import_info(&facts.occurrences),
2483                import
2484            );
2485        }
2486        let metadata = |name: &str| {
2487            parsed
2488                .signature_metadata
2489                .iter()
2490                .find(|(unit, _)| unit.short_name() == name)
2491                .unwrap()
2492                .1
2493        };
2494        assert!(metadata("public")[0].is_declaration_only());
2495        assert!(!metadata("ordinary")[0].is_declaration_only());
2496    }
2497
2498    #[test]
2499    #[brokk_bifrost_test_macros::hardwon(
2500        "1fa35d421feb: aliased import leaves pointed at imported-name ranges instead of the whole import statement"
2501    )]
2502    fn aliased_import_leaves_keep_the_statement_and_exact_binder_tokens() {
2503        let source = "from pkg import alpha as beta, beta as alpha\n";
2504        let parsed = parse(source);
2505        assert_eq!(parsed.imports.len(), 2);
2506        for (import, name) in parsed.imports.iter().zip(["beta", "alpha"]) {
2507            assert_eq!(import.path.as_ref().unwrap().declaration_start_byte, 0);
2508            let span = import.binder_span.as_ref().expect("exact alias binder");
2509            assert_eq!(&source[span.start_byte..span.end_byte], name);
2510        }
2511    }
2512
2513    #[test]
2514    fn parameter_offsets_use_ast_tokens_instead_of_annotation_spelling() {
2515        let source = "@decorate(\"(first, second)\")\ndef call(first: Literal[\"second\"], second: int):\n    pass\n";
2516        let parsed = parse(source);
2517        let metadata = parsed
2518            .signature_metadata
2519            .values()
2520            .flatten()
2521            .find(|metadata| metadata.parameters().len() == 2)
2522            .expect("parameter metadata");
2523        let second = &metadata.parameters()[1];
2524        assert_eq!(
2525            &metadata.label()[second.start_byte()..second.end_byte()],
2526            "second"
2527        );
2528        assert_eq!(
2529            second.start_byte(),
2530            metadata.label().rfind("second:").unwrap()
2531        );
2532    }
2533
2534    #[test]
2535    fn multiline_parameter_metadata_keeps_each_formal_name_at_its_ast_offset() {
2536        let source = concat!(
2537            "@decorate\n",
2538            "def call(\n",
2539            "    bare,\n",
2540            "    typed: T,\n",
2541            "    default=1,\n",
2542            "    typed_default: U = 2,\n",
2543            "    *args: V,\n",
2544            "    required_kw,\n",
2545            "    **kwargs: W,\n",
2546            "):\n",
2547            "    pass\n",
2548        );
2549        let parsed = parse(source);
2550        let metadata = parsed
2551            .signature_metadata
2552            .values()
2553            .flatten()
2554            .find(|metadata| metadata.parameters().len() == 7)
2555            .expect("multiline callable parameter metadata");
2556        let names = metadata
2557            .parameters()
2558            .iter()
2559            .map(|parameter| &metadata.label()[parameter.start_byte()..parameter.end_byte()])
2560            .collect::<Vec<_>>();
2561        assert_eq!(
2562            names,
2563            [
2564                "bare",
2565                "typed",
2566                "default",
2567                "typed_default",
2568                "args",
2569                "required_kw",
2570                "kwargs"
2571            ]
2572        );
2573        assert!(metadata.label().contains("    typed_default: U = 2,"));
2574        assert!(metadata.label().ends_with(" ..."));
2575    }
2576
2577    #[test]
2578    fn crlf_multiline_parameter_metadata_keeps_each_formal_name_at_its_ast_offset() {
2579        let source = concat!(
2580            "@decorate\r\n",
2581            "def call(\r\n",
2582            "    bare,\r\n",
2583            "    typed: T,\r\n",
2584            "    default=1,\r\n",
2585            "):\r\n",
2586            "    pass\r\n",
2587        );
2588        let parsed = parse(source);
2589        let metadata = parsed
2590            .signature_metadata
2591            .values()
2592            .flatten()
2593            .find(|metadata| metadata.parameters().len() == 3)
2594            .expect("multiline callable parameter metadata");
2595        let names = metadata
2596            .parameters()
2597            .iter()
2598            .map(|parameter| &metadata.label()[parameter.start_byte()..parameter.end_byte()])
2599            .collect::<Vec<_>>();
2600        assert_eq!(names, ["bare", "typed", "default"]);
2601    }
2602
2603    #[test]
2604    fn crlf_parameter_metadata_preserves_normalized_signature_offsets() {
2605        for indentation in ["", "    "] {
2606            let header = concat!(
2607                "@decorate\r\n",
2608                "def call(\r\n",
2609                "    first: T,\r\n",
2610                "    second: U = 2,\r\n",
2611                "):\r\n",
2612                "    pass\r\n",
2613            );
2614            let source = if indentation.is_empty() {
2615                header.to_owned()
2616            } else {
2617                format!(
2618                    "class Service:\r\n{}",
2619                    header
2620                        .split_inclusive('\n')
2621                        .map(|line| format!("{indentation}{line}"))
2622                        .collect::<String>()
2623                )
2624            };
2625            let parsed = parse(&source);
2626            let metadata = parsed
2627                .signature_metadata
2628                .values()
2629                .flatten()
2630                .find(|metadata| metadata.parameters().len() == 2)
2631                .expect("CRLF callable metadata");
2632            assert!(!metadata.label().contains('\r'));
2633            let labels = metadata
2634                .parameters()
2635                .iter()
2636                .map(|parameter| &metadata.label()[parameter.start_byte()..parameter.end_byte()])
2637                .collect::<Vec<_>>();
2638            assert_eq!(labels, ["first", "second"]);
2639        }
2640    }
2641
2642    #[test]
2643    #[brokk_bifrost_test_macros::hardwon(
2644        "#4455: multiline Python parameter offsets dropped a newline before an unindented label"
2645    )]
2646    fn multiline_parameter_offsets_follow_ast_ranges_through_header_normalization() {
2647        let source = concat!(
2648            "def manyargs(first: Annotated[int, 'second'] = second, second: int = 1,\r\n",
2649            "# comment between parameters\r\n",
2650            "third: int = second,\r\n",
2651            "/,\r\n",
2652            "*args: object,\r\n",
2653            "keyword: int = 2,\r\n",
2654            "**kwargs: object,\r\n",
2655            "):\r\n",
2656            "    pass\r\n",
2657            "def keyword_only(\r\n",
2658            "positional,\r\n",
2659            "*,\r\n",
2660            "required,\r\n",
2661            "):\r\n",
2662            "    pass\r\n",
2663            "class Service:\n",
2664            "\tdef tabbed(\n",
2665            "\t\tfirst,\n",
2666            "\t\tsecond,\n",
2667            "\t):\n",
2668            "\t\tpass\n",
2669        );
2670        let parsed = parse(source);
2671        let metadata = |name: &str| {
2672            parsed
2673                .signature_metadata
2674                .iter()
2675                .find(|(unit, _)| unit.short_name() == name)
2676                .unwrap_or_else(|| {
2677                    let available = parsed
2678                        .signature_metadata
2679                        .keys()
2680                        .map(|unit| unit.short_name())
2681                        .collect::<Vec<_>>();
2682                    panic!("callable metadata for {name}; available names: {available:?}");
2683                })
2684                .1
2685                .first()
2686                .expect("one signature")
2687        };
2688        let parameter_names = |name: &str| {
2689            let metadata = metadata(name);
2690            metadata
2691                .parameters()
2692                .iter()
2693                .map(|parameter| &metadata.label()[parameter.start_byte()..parameter.end_byte()])
2694                .collect::<Vec<_>>()
2695        };
2696
2697        let manyargs = metadata("manyargs");
2698        assert_eq!(
2699            parameter_names("manyargs"),
2700            ["first", "second", "third", "args", "keyword", "kwargs"]
2701        );
2702        assert!(
2703            manyargs
2704                .label()
2705                .contains("# comment between parameters\nthird: int")
2706        );
2707        assert!(manyargs.label().contains("\n/,\n*args: object"));
2708        assert!(!manyargs.label().contains('\r'));
2709        assert_eq!(parameter_names("keyword_only"), ["positional", "required"]);
2710        assert_eq!(parameter_names("Service.tabbed"), ["first", "second"]);
2711    }
2712
2713    #[test]
2714    fn empty_and_recovered_python_publish_canonical_structural_facts() {
2715        for source in [
2716            "",
2717            "class Broken(:\n    pass\ndef valid(value):\n    return value\n",
2718        ] {
2719            let parsed = parse(source);
2720            let facts = parsed.source_facts.expect("complete producer publication");
2721            assert_eq!(facts.source_bytes, source.len());
2722            assert!(facts.python.is_some());
2723            assert_eq!(parsed.resolution_facts, Default::default());
2724            assert!(
2725                facts
2726                    .occurrences
2727                    .occurrences()
2728                    .iter()
2729                    .all(|occurrence| occurrence.range.end_byte <= source.len())
2730            );
2731        }
2732    }
2733
2734    #[test]
2735    fn unmounted_annotated_functions_keep_source_declarations_and_return_facts() {
2736        let source = "if outer:\n    if inner:\n        def hidden(value: int) -> Hidden:\n            pass\n\ndef visible() -> Visible:\n    pass\n";
2737        let parsed = parse(source);
2738        let facts = parsed
2739            .source_facts
2740            .as_ref()
2741            .expect("canonical Python facts");
2742        let python = facts.python.as_ref().expect("Python source facts");
2743        assert_eq!(python.callable_returns.len(), 2);
2744
2745        let fact_named = |name: &str| {
2746            python
2747                .callable_returns
2748                .iter()
2749                .find(|fact| {
2750                    let declaration = facts.occurrences.declaration(fact.declaration);
2751                    let Some(name_occurrence) = declaration.name else {
2752                        return false;
2753                    };
2754                    let range = facts.occurrences.occurrence(name_occurrence).range;
2755                    &source[range.start_byte..range.end_byte] == name
2756                })
2757                .expect("callable return fact")
2758        };
2759        let hidden = fact_named("hidden");
2760        let hidden_declaration = facts.occurrences.declaration(hidden.declaration);
2761        let hidden_range = facts
2762            .occurrences
2763            .occurrence(hidden_declaration.occurrence)
2764            .range;
2765        assert_eq!(
2766            &source[hidden_range.start_byte..hidden_range.start_byte + 3],
2767            "def"
2768        );
2769        let hidden_annotation = hidden.return_annotation.expect("hidden annotation");
2770        let hidden_annotation_range = facts.occurrences.occurrence(hidden_annotation).range;
2771        assert_eq!(
2772            &source[hidden_annotation_range.start_byte..hidden_annotation_range.end_byte],
2773            "Hidden"
2774        );
2775        assert!(
2776            parsed
2777                .source_declaration_units
2778                .iter()
2779                .all(|(_, unit)| unit.short_name() != "hidden")
2780        );
2781
2782        let visible = fact_named("visible");
2783        assert!(visible.return_annotation.is_some());
2784        assert!(visible.runtime_type.is_some());
2785        assert!(
2786            parsed
2787                .source_declaration_units
2788                .iter()
2789                .any(|(_, unit)| unit.short_name() == "visible")
2790        );
2791        let visible_metadata = parsed
2792            .signature_metadata
2793            .iter()
2794            .find(|(unit, _)| unit.short_name() == "visible")
2795            .and_then(|(_, metadata)| metadata.first())
2796            .expect("visible signature metadata");
2797        assert_eq!(visible_metadata.return_type_text(), Some("Visible"));
2798        assert_eq!(
2799            visible_metadata.return_type_identity(),
2800            visible.runtime_type.as_ref()
2801        );
2802    }
2803
2804    #[test]
2805    fn return_fact_preserves_unsupported_annotation_without_runtime_identity() {
2806        let source = "def unsupported() -> Left | Right:\n    pass\n\ndef plain():\n    pass\n";
2807        let parsed = parse(source);
2808        let facts = parsed
2809            .source_facts
2810            .as_ref()
2811            .expect("canonical Python facts");
2812        let python = facts.python.as_ref().expect("Python source facts");
2813        let by_name = |name: &str| {
2814            python
2815                .callable_returns
2816                .iter()
2817                .find(|fact| {
2818                    let declaration = facts.occurrences.declaration(fact.declaration);
2819                    let Some(name_occurrence) = declaration.name else {
2820                        return false;
2821                    };
2822                    let range = facts.occurrences.occurrence(name_occurrence).range;
2823                    &source[range.start_byte..range.end_byte] == name
2824                })
2825                .expect("callable return fact")
2826        };
2827        let unsupported = by_name("unsupported");
2828        assert!(unsupported.return_annotation.is_some());
2829        assert!(unsupported.runtime_type.is_none());
2830        let plain = by_name("plain");
2831        assert!(plain.return_annotation.is_none());
2832        assert!(plain.runtime_type.is_none());
2833    }
2834
2835    #[test]
2836    #[brokk_bifrost_test_macros::hardwon(
2837        "45e71db39f13: generic Python annotations mistook a list element for the returned class owner"
2838    )]
2839    fn return_annotation_references_preserve_runtime_owners() {
2840        let source = "def generic() -> list[User]:\n    pass\n\ndef nested() -> list[pkg.User]:\n    pass\n\ndef direct() -> pkg.User:\n    pass\n\ndef quoted() -> \"pkg.User\":\n    pass\n\ndef unsupported() -> Left | Right:\n    pass\n";
2841        let parsed = parse(source);
2842        let facts = parsed
2843            .source_facts
2844            .as_ref()
2845            .expect("canonical Python facts");
2846        let python = facts.python.as_ref().expect("Python source facts");
2847        let fact_named = |name: &str| {
2848            python
2849                .callable_returns
2850                .iter()
2851                .find(|fact| {
2852                    let declaration = facts.occurrences.declaration(fact.declaration);
2853                    let Some(name_occurrence) = declaration.name else {
2854                        return false;
2855                    };
2856                    let range = facts.occurrences.occurrence(name_occurrence).range;
2857                    &source[range.start_byte..range.end_byte] == name
2858                })
2859                .expect("callable return fact")
2860        };
2861        let generic = fact_named("generic");
2862        assert_eq!(
2863            generic
2864                .annotation_references
2865                .iter()
2866                .map(|reference| &reference.name)
2867                .collect::<Vec<_>>(),
2868            vec![&PythonAnnotationReferenceName::Lexical("list".to_string())]
2869        );
2870
2871        // Generic arguments describe elements, not the returned runtime owner.
2872        let nested = fact_named("nested");
2873        assert_eq!(nested.annotation_references.len(), 1);
2874        assert!(matches!(
2875            &nested.annotation_references[0].name,
2876            PythonAnnotationReferenceName::Lexical(name) if name == "list"
2877        ));
2878
2879        let direct = fact_named("direct");
2880        assert_eq!(direct.annotation_references.len(), 3);
2881        assert_eq!(direct.annotation_references[0].subtree_end, 3);
2882        assert_eq!(direct.annotation_references[0].lookup_depth, 1);
2883        assert!(matches!(
2884            &direct.annotation_references[0].name,
2885            PythonAnnotationReferenceName::Qualified(path)
2886                if path == &vec!["pkg".to_string(), "User".to_string()]
2887        ));
2888
2889        let quoted = fact_named("quoted");
2890        assert_eq!(quoted.annotation_references.len(), 1);
2891        assert!(matches!(
2892            &quoted.annotation_references[0].name,
2893            PythonAnnotationReferenceName::Lexical(name) if name == "pkg.User"
2894        ));
2895        assert_eq!(quoted.annotation_references[0].lookup_depth, 2);
2896
2897        let unsupported = fact_named("unsupported");
2898        assert!(
2899            unsupported
2900                .annotation_references
2901                .iter()
2902                .all(|reference| !matches!(
2903                    &reference.name,
2904                    PythonAnnotationReferenceName::Qualified(_)
2905                ))
2906        );
2907    }
2908}
2909
2910/// Every target a possibly chained assignment binds.
2911///
2912/// Python's `encrypt = decrypt = process` binds both names, but the grammar
2913/// spells it as one `assignment` whose `right` is another `assignment`. Reading
2914/// only the outermost `left` declares `encrypt` and silently drops `decrypt`,
2915/// so the alias reads as an absent member on the class that defines it.
2916fn python_chained_assignment_targets<'tree>(assignment: Node<'tree>) -> Vec<Node<'tree>> {
2917    super::imports::python_assignment_chain(assignment).0
2918}
2919
2920#[cfg(test)]
2921mod supertype_tests {
2922    use super::extract_python_supertypes;
2923    use tree_sitter::Node;
2924
2925    fn class_node<'tree>(tree: &'tree tree_sitter::Tree, source: &str) -> Node<'tree> {
2926        let mut cursor = tree.root_node().walk();
2927        tree.root_node()
2928            .named_children(&mut cursor)
2929            .find(|node| node.kind() == "class_definition")
2930            .unwrap_or_else(|| panic!("source declares a class: {source}"))
2931    }
2932
2933    fn parse(source: &str) -> tree_sitter::Tree {
2934        super::parse_python_tree(source).expect("the source parses")
2935    }
2936
2937    #[test]
2938    #[brokk_bifrost_test_macros::hardwon(
2939        "50c1574b2070: unsupported Python positional bases were dropped and created false complete absence"
2940    )]
2941    fn every_positional_base_contributes_a_spelling() {
2942        for (source, expected) in [
2943            ("class A(Base): pass\n", vec!["Base"]),
2944            ("class A(pkg.Base): pass\n", vec!["pkg.Base"]),
2945            // The generic origin is the class the runtime inherits; dropping
2946            // a subscripted base made an incomplete hierarchy look complete.
2947            ("class A(Base[int]): pass\n", vec!["Base"]),
2948            ("class A(pkg.Base[str, int]): pass\n", vec!["pkg.Base"]),
2949            (
2950                "class A(Mapping[str, Any], Base): pass\n",
2951                vec!["Mapping", "Base"],
2952            ),
2953            // Not a base: a keyword argument configures class creation.
2954            ("class A(Base, metaclass=Meta): pass\n", vec!["Base"]),
2955            ("class A(metaclass=Meta): pass\n", Vec::new()),
2956            // Unnameable bases still register, so resolution reports an
2957            // unresolved base rather than a complete member list.
2958            (
2959                "class A(namedtuple(\"P\", \"x\")): pass\n",
2960                vec!["namedtuple(\"P\", \"x\")"],
2961            ),
2962            ("class A(*bases): pass\n", vec!["*bases"]),
2963            ("class A: pass\n", Vec::new()),
2964        ] {
2965            let tree = parse(source);
2966            let node = class_node(&tree, source);
2967            assert_eq!(
2968                extract_python_supertypes(node, source),
2969                expected,
2970                "supertypes of {source}"
2971            );
2972        }
2973    }
2974}
2975
2976#[cfg(test)]
2977mod callable_modifier_tests {
2978    use super::*;
2979
2980    /// The Python half of #3451. Every declaration this walk mints as a
2981    /// callable states whether it binds an instance receiver, read from its own
2982    /// decorator and definition nodes: `@staticmethod` and `@classmethod`
2983    /// remove the receiver, every other `def` in a class body keeps it, and a
2984    /// module-level or async function has none to keep. `receiver_contract_of`
2985    /// reports no contract at all for a callable whose adapter never inspected
2986    /// modifiers, so without this every Python procedure key is refused.
2987    ///
2988    /// The kinds that are deliberately not keyable are asserted here with their
2989    /// reason, so the coverage is the walk's decision rather than an omission:
2990    ///
2991    /// - `@property` is a `Field`, because Python reads it as an attribute; a
2992    ///   member whose unit kind is not a callable minted no key before and does
2993    ///   not now.
2994    /// - `assigned = lambda ...` is a `Field` too: the module walk records the
2995    ///   assignment as a field and never mints a callable declaration for the
2996    ///   lambda, so there are no signature modifiers to read and nothing for a
2997    ///   reviewed summary to bind.
2998    /// - `inner`, a function defined inside another function's body, is a local
2999    ///   binding of its enclosing function rather than a member of any owner, so
3000    ///   this walk mints no declaration, no `SignatureMetadata`, and no
3001    ///   procedure key for it.
3002    #[test]
3003    #[brokk_bifrost_test_macros::hardwon(
3004        "#3451: Python callable receiver modifiers were missing, leaving workspace procedures unkeyable"
3005    )]
3006    fn callable_metadata_records_python_receiver_contracts_structurally() {
3007        let source = "def free(value):\n    return value\n\nclass Widget:\n    def __init__(self, spec):\n        self.spec = spec\n\n    def render(self, target):\n        return target\n\n    @staticmethod\n    def build(spec):\n        return spec\n\n    @classmethod\n    def measure(cls, target):\n        return target\n\n    @property\n    def label(self):\n        return \"widget\"\n\nasync def fetch_all(url):\n    return url\n\ndef outer(seed):\n    def inner(value):\n        return value\n    return inner(seed)\n\nassigned = lambda value: value\n";
3008        let file = ProjectFile::new(std::env::temp_dir(), "widget.py");
3009        let tree = parse_python_tree(source).expect("parse the Python fixture");
3010        let parsed = parse_python_file(
3011            &brokk_bifrost_core::analyzer::project::TestProject::new(
3012                file.root(),
3013                brokk_bifrost_core::analyzer::Language::Python,
3014            ),
3015            &file,
3016            source,
3017            &tree,
3018        );
3019
3020        let entry = |fq_name: &str| {
3021            parsed
3022                .signature_metadata
3023                .iter()
3024                .find(|(unit, _)| unit.fq_name() == fq_name)
3025                .unwrap_or_else(|| {
3026                    panic!(
3027                        "missing Python declaration {fq_name}; recorded {:?}",
3028                        parsed
3029                            .signature_metadata
3030                            .keys()
3031                            .map(CodeUnit::fq_name)
3032                            .collect::<Vec<_>>()
3033                    )
3034                })
3035        };
3036        let modifiers = |fq_name: &str| {
3037            let (_, entries) = entry(fq_name);
3038            let metadata = entries
3039                .first()
3040                .unwrap_or_else(|| panic!("{fq_name} carries no signature metadata"));
3041            assert!(
3042                metadata.callable_modifiers_recorded(),
3043                "{fq_name} must record that the walk read its declaration shape"
3044            );
3045            (
3046                metadata.callable_is_static(),
3047                metadata.callable_is_constructor(),
3048                metadata.parameters().len(),
3049            )
3050        };
3051
3052        assert_eq!(modifiers("widget.free"), (false, false, 1));
3053        assert_eq!(
3054            modifiers("widget.Widget.__init__"),
3055            (false, false, 2),
3056            "`__init__` is an ordinary instance method, exactly as Ruby's `initialize`"
3057        );
3058        assert_eq!(modifiers("widget.Widget.render"), (false, false, 2));
3059        assert_eq!(
3060            modifiers("widget.Widget.build"),
3061            (true, false, 1),
3062            "`@staticmethod` binds no instance receiver"
3063        );
3064        assert_eq!(
3065            modifiers("widget.Widget.measure"),
3066            (true, false, 2),
3067            "`@classmethod` receives the class, not an instance"
3068        );
3069        assert_eq!(
3070            modifiers("widget.fetch_all"),
3071            (false, false, 1),
3072            "an async def is the same function_definition node"
3073        );
3074        assert_eq!(modifiers("widget.outer"), (false, false, 1));
3075
3076        let (property_unit, property_entries) = entry("widget.Widget.label");
3077        assert_eq!(
3078            property_unit.kind(),
3079            CodeUnitType::Field,
3080            "`@property` is read as an attribute, so it mints a Field and no key"
3081        );
3082        assert!(!property_unit.is_callable());
3083        assert!(
3084            property_entries
3085                .first()
3086                .expect("the property still carries signature metadata")
3087                .callable_modifiers_recorded(),
3088            "the shared metadata path records modifiers for every declaration it mints"
3089        );
3090
3091        // The assignment walk records `assigned` as a field through
3092        // `add_signature`, which mints no `SignatureMetadata` at all, so there
3093        // are no callable modifiers to record and nothing a reviewed summary
3094        // could bind.
3095        let lambda_unit = parsed
3096            .declarations()
3097            .iter()
3098            .find(|unit| unit.fq_name() == "widget.assigned")
3099            .expect("the module walk still records the name-bound lambda's assignment");
3100        assert_eq!(
3101            lambda_unit.kind(),
3102            CodeUnitType::Field,
3103            "a name-bound lambda is recorded as a field, not as a callable declaration"
3104        );
3105        assert!(
3106            !parsed.signature_metadata.contains_key(lambda_unit),
3107            "a field assignment carries signature text but no callable signature metadata"
3108        );
3109        assert!(
3110            parsed
3111                .declarations()
3112                .iter()
3113                .all(|unit| unit.terminal_name() != "inner"),
3114            "a function nested in another function is a local binding, not a declaration"
3115        );
3116    }
3117}