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;
3use crate::syntax::{
4    PythonOverloadDecoratorBindings, expression_name_node, python_plain_string_literal,
5};
6use brokk_bifrost_core::analyzer::fq_name::{FqName, SegmentId, SegmentKind, segment_interner};
7use brokk_bifrost_core::analyzer::model::{
8    CodeUnitType, DispatchExtensibility, ParameterMetadata, SignatureMetadata,
9    StructuredImportPathKind,
10};
11use brokk_bifrost_core::analyzer::parsed_file::ParsedFile;
12use brokk_bifrost_core::analyzer::tree_walk::{WalkControl, walk_named_tree_preorder};
13use brokk_bifrost_core::analyzer::{CodeUnit, ProjectFile};
14use brokk_bifrost_core::hash::{HashMap, HashSet};
15use brokk_bifrost_core::path_normalization::NormalizePath;
16use brokk_bifrost_core::text_utils::{compute_line_starts, find_line_index_for_offset};
17use std::path::{Path, PathBuf};
18use tree_sitter::{Node, Parser, Tree};
19
20/// Intern one qualified-name segment in the process-global interner.
21fn py_segment(text: &str, kind: SegmentKind) -> SegmentId {
22    segment_interner().intern(text, kind)
23}
24
25/// Build the structured module-path prefix for a Python declaration.
26///
27/// Ordinary modules render as a dotted path such as `mypkg.subpkg.mymodule`,
28/// with each original path component represented by one
29/// [`SegmentKind::Package`] segment. Hidden directories such as `.agent` and
30/// `.github` are also legal components in the analyzer's path-derived Python
31/// convention, but their leading dot is ambiguous after a rendered name has
32/// been joined.
33///
34/// Build the structured name from the file path's original components so
35/// hidden-directory segments stay intact in cold extraction, synthesized module
36/// units, and persisted reconstruction.
37pub fn python_module_fq(file: &ProjectFile) -> FqName {
38    python_module_fq_from_components(&python_module_components(file))
39}
40
41fn python_module_fq_from_components(components: &[String]) -> FqName {
42    let mut fq = FqName::new();
43    for component in components {
44        fq.push(py_segment(component, SegmentKind::Package));
45    }
46    fq
47}
48
49fn python_module_components(file: &ProjectFile) -> Vec<String> {
50    let mut components = python_package_components_for_file(file);
51    let module_name = file
52        .rel_path()
53        .file_stem()
54        .and_then(|stem| stem.to_str())
55        .unwrap_or_default();
56    if module_name != "__init__" || components.is_empty() {
57        components.push(module_name.to_string());
58    }
59    components
60}
61
62fn python_package_components_for_file(file: &ProjectFile) -> Vec<String> {
63    let Some(parent_rel) = file.rel_path().parent() else {
64        return Vec::new();
65    };
66    if parent_rel.as_os_str().is_empty() {
67        return Vec::new();
68    }
69
70    if let Some(import_root_rel) = python_configured_import_root(file, parent_rel)
71        && let Ok(relative_package) = parent_rel.strip_prefix(import_root_rel)
72    {
73        return path_components(relative_package);
74    }
75
76    let mut effective_package_root_rel: Option<&Path> = None;
77    let mut current_rel = Some(parent_rel);
78    while let Some(path) = current_rel {
79        if file.root().join(path).join("__init__.py").exists() {
80            effective_package_root_rel = Some(path);
81        }
82        current_rel = path.parent();
83    }
84
85    let relative_package = match effective_package_root_rel {
86        Some(package_root_rel) => package_root_rel
87            .parent()
88            .and_then(|import_root_rel| parent_rel.strip_prefix(import_root_rel).ok())
89            .unwrap_or(parent_rel),
90        None => parent_rel,
91    };
92    path_components(relative_package)
93}
94
95/// Find the nearest setuptools import root that contains this source file.
96///
97/// `pyproject.toml` roots take precedence over legacy `setup.py` evidence at
98/// each ancestor. An unrelated or malformed packaging file does not change the
99/// existing `__init__.py` package-root convention.
100fn python_configured_import_root(file: &ProjectFile, parent_rel: &Path) -> Option<PathBuf> {
101    let mut manifest_dir_rel = Some(parent_rel);
102    while let Some(directory) = manifest_dir_rel {
103        let manifest_dir = file.root().join(directory);
104        let mut roots = setuptools_where_entries(&manifest_dir.join("pyproject.toml"))
105            .iter()
106            .map(|entry| manifest_dir.join(entry).normalize())
107            .filter_map(|root| root.strip_prefix(file.root()).ok().map(Path::to_path_buf))
108            .filter(|root| parent_rel.starts_with(root))
109            .collect::<Vec<_>>();
110        roots.sort_by_key(|root| root.components().count());
111        if let Some(root) = roots.pop() {
112            return Some(root);
113        }
114        if let Some(package_dir) = setuptools_setup_py_import_root(&manifest_dir.join("setup.py")) {
115            let root = manifest_dir.join(package_dir).normalize();
116            if let Ok(root) = root.strip_prefix(file.root())
117                && parent_rel.starts_with(root)
118            {
119                return Some(root.to_path_buf());
120            }
121        }
122        manifest_dir_rel = directory.parent();
123    }
124    None
125}
126
127#[derive(Clone, Copy, PartialEq, Eq)]
128struct FileStamp {
129    len: u64,
130    modified: Option<std::time::SystemTime>,
131}
132
133fn file_stamp(path: &Path) -> Option<FileStamp> {
134    let metadata = std::fs::metadata(path).ok()?;
135    Some(FileStamp {
136        len: metadata.len(),
137        modified: metadata.modified().ok(),
138    })
139}
140
141/// One manifest's memoized `tool.setuptools.packages.find.where` entries.
142///
143/// The stamp is what the memo is validated against, so a manifest edited in a
144/// long-running server is re-read instead of being answered from a stale parse.
145struct ManifestWhereEntries {
146    stamp: FileStamp,
147    entries: Vec<String>,
148}
149
150/// Read the setuptools package-discovery roots declared by one `pyproject.toml`.
151///
152/// Module identity is resolved once per declaration, not once per file, so this
153/// sits on a hot path: a full read plus TOML parse per call made every Python
154/// identity question proportional to the size of the nearest manifest. The
155/// parse is therefore memoized per manifest and revalidated with one `stat`,
156/// which keeps an edited manifest honored while the steady-state cost is a
157/// metadata probe. An absent, unreadable, malformed, or non-setuptools manifest
158/// declares no roots and leaves the `__init__.py` package-root convention in
159/// charge.
160fn setuptools_where_entries(manifest: &Path) -> Vec<String> {
161    static MEMO: std::sync::OnceLock<
162        std::sync::RwLock<std::collections::HashMap<PathBuf, ManifestWhereEntries>>,
163    > = std::sync::OnceLock::new();
164    let memo = MEMO.get_or_init(Default::default);
165
166    let Some(stamp) = file_stamp(manifest) else {
167        return Vec::new();
168    };
169    if let Some(cached) = memo.read().expect("manifest memo").get(manifest)
170        && cached.stamp == stamp
171    {
172        return cached.entries.clone();
173    }
174
175    let entries = parse_setuptools_where_entries(manifest);
176    memo.write().expect("manifest memo").insert(
177        manifest.to_path_buf(),
178        ManifestWhereEntries {
179            stamp,
180            entries: entries.clone(),
181        },
182    );
183    entries
184}
185
186/// A memoized static package root recovered from one legacy `setup.py`.
187/// `Some(PathBuf::new())` represents the setup script's directory, which is
188/// setuptools' default when no `package_dir` is supplied.
189struct SetupPyImportRoot {
190    stamp: FileStamp,
191    package_dir: Option<PathBuf>,
192}
193
194/// Read a legacy setuptools import root without executing `setup.py`.
195///
196/// This is intentionally narrower than Python's runtime packaging semantics:
197/// a direct top-level call to an imported setuptools or distutils.core
198/// `setup` binding must provide a `packages` argument. A literal `package_dir`
199/// establishes the root regardless of the package expression; without one,
200/// the package expression must be either a nonempty literal collection or a
201/// supported setuptools discovery call. Unsupported argument shapes or a
202/// shadowed import leave the source path-derived.
203fn setuptools_setup_py_import_root(setup_py: &Path) -> Option<PathBuf> {
204    static MEMO: std::sync::OnceLock<
205        std::sync::RwLock<std::collections::HashMap<PathBuf, SetupPyImportRoot>>,
206    > = std::sync::OnceLock::new();
207    let memo = MEMO.get_or_init(Default::default);
208
209    let stamp = file_stamp(setup_py)?;
210    if let Some(cached) = memo.read().expect("setup.py memo").get(setup_py)
211        && cached.stamp == stamp
212    {
213        return cached.package_dir.clone();
214    }
215
216    let package_dir = parse_setuptools_setup_py_import_root(setup_py);
217    memo.write().expect("setup.py memo").insert(
218        setup_py.to_path_buf(),
219        SetupPyImportRoot {
220            stamp,
221            package_dir: package_dir.clone(),
222        },
223    );
224    package_dir
225}
226
227fn parse_setuptools_setup_py_import_root(setup_py: &Path) -> Option<PathBuf> {
228    let source = std::fs::read_to_string(setup_py).ok()?;
229    let tree = parse_python_tree(&source)?;
230    let root = tree.root_node();
231    if root.has_error() {
232        return None;
233    }
234    let mut import_root = None;
235    for (call, setup_bindings) in setup_py_setup_calls(&source, root) {
236        let candidate = setup_py_import_root_from_call(call, &source, &setup_bindings)?;
237        if import_root
238            .replace(candidate.clone())
239            .is_some_and(|root| root != candidate)
240        {
241            return None;
242        }
243    }
244    import_root
245}
246
247/// Read the `python_requires` specifier a legacy `setup.py` declares.
248///
249/// This is setuptools' spelling of `pyproject.toml`'s `requires-python`, and it
250/// is what selects the standard-library semantic pack for a project that has no
251/// PEP 621 manifest. The script is read, never executed: only a literal string
252/// passed to a top-level call to an imported `setup` binding counts, and two
253/// calls that disagree declare nothing.
254pub fn setuptools_setup_py_python_requires(setup_py: &Path) -> Option<String> {
255    let source = std::fs::read_to_string(setup_py).ok()?;
256    let tree = parse_python_tree(&source)?;
257    let root = tree.root_node();
258    if root.has_error() {
259        return None;
260    }
261    let mut requirement: Option<String> = None;
262    for (call, _) in setup_py_setup_calls(&source, root) {
263        let Some(arguments) = call.child_by_field_name("arguments") else {
264            continue;
265        };
266        if arguments.kind() != "argument_list" {
267            continue;
268        }
269        let mut cursor = arguments.walk();
270        for argument in arguments.named_children(&mut cursor) {
271            if argument.kind() != "keyword_argument" {
272                continue;
273            }
274            let Some(name) = argument.child_by_field_name("name") else {
275                continue;
276            };
277            if py_node_text(name, &source).trim() != "python_requires" {
278                continue;
279            }
280            let value = argument.child_by_field_name("value")?;
281            let declared = python_plain_string_literal(value, &source)?.to_owned();
282            if requirement
283                .replace(declared.clone())
284                .is_some_and(|previous| previous != declared)
285            {
286                return None;
287            }
288        }
289    }
290    requirement
291}
292
293/// Every top-level call to an imported setuptools `setup` binding, paired with
294/// the import bindings that were live where the call appears.
295///
296/// The walk tracks bindings across the module's top-level statements, so a name
297/// a later statement rebinds stops being read as setuptools' `setup`. It does
298/// not enter function, class, or lambda bodies: a call there is conditional on
299/// something this reader does not evaluate.
300fn setup_py_setup_calls<'tree>(
301    source: &str,
302    root: Node<'tree>,
303) -> Vec<(Node<'tree>, HashMap<Vec<String>, String>)> {
304    let mut setup_bindings: HashMap<Vec<String>, String> = HashMap::default();
305    let mut calls = Vec::new();
306    let mut cursor = root.walk();
307
308    for statement in root.named_children(&mut cursor) {
309        if matches!(
310            statement.kind(),
311            "import_statement" | "import_from_statement"
312        ) {
313            for binding in setup_py_bound_names(statement, source) {
314                setup_bindings.retain(|path, _| path.first() != Some(&binding));
315            }
316            for import in python_import_infos_from_node(statement, source) {
317                if import.is_wildcard {
318                    setup_bindings.clear();
319                    continue;
320                }
321                let Some(path) = import.path else { continue };
322                let segments = path.segments.iter().map(String::as_str).collect::<Vec<_>>();
323                match path.kind {
324                    Some(StructuredImportPathKind::ImportFrom) => {
325                        let function_name = match segments.as_slice() {
326                            ["setuptools", "setup"] | ["distutils", "core", "setup"] => "setup",
327                            ["setuptools", "find_packages"] => "find_packages",
328                            ["setuptools", "find_namespace_packages"] => "find_namespace_packages",
329                            _ => continue,
330                        };
331                        setup_bindings.insert(
332                            vec![import.identifier.expect("imported function binds a name")],
333                            function_name.to_string(),
334                        );
335                    }
336                    Some(StructuredImportPathKind::Namespace) => {
337                        let function_names = match segments.as_slice() {
338                            ["setuptools"] => {
339                                ["setup", "find_packages", "find_namespace_packages"].as_slice()
340                            }
341                            ["distutils", "core"] => ["setup"].as_slice(),
342                            _ => continue,
343                        };
344                        let binding_prefix = import
345                            .alias
346                            .map(|alias| vec![alias])
347                            .unwrap_or_else(|| path.segments.clone());
348                        for function_name in function_names {
349                            let mut callable = binding_prefix.clone();
350                            callable.push((*function_name).to_string());
351                            setup_bindings.insert(callable, (*function_name).to_string());
352                        }
353                    }
354                    _ => continue,
355                }
356            }
357            continue;
358        }
359
360        if statement.kind() == "expression_statement"
361            && statement.named_child_count() == 1
362            && let Some(call) = statement.named_child(0)
363            && call.kind() == "call"
364            && setup_py_call_imported_function(call, source, &setup_bindings)
365                .is_some_and(|function| function == "setup")
366        {
367            calls.push((call, setup_bindings.clone()));
368        }
369
370        for binding in setup_py_bound_names(statement, source) {
371            setup_bindings.retain(|path, _| path.first() != Some(&binding));
372        }
373    }
374    calls
375}
376
377/// Return names that a top-level statement binds in the module scope.
378///
379/// The walk is iterative and does not enter function, class, or lambda bodies.
380/// Bindings in control-flow statements still invalidate an imported setup name:
381/// their execution is conditional, so retaining the import would overclaim its
382/// identity at a later top-level call.
383fn setup_py_bound_names(statement: Node<'_>, source: &str) -> Vec<String> {
384    let mut names = Vec::new();
385    let mut pending = vec![statement];
386    while let Some(node) = pending.pop() {
387        for binding in python_direct_scope_bindings_bounded(node, source, || true)
388            .expect("unbounded setup.py binding walk")
389        {
390            let name = py_node_text(binding.declaration, source).trim();
391            if !name.is_empty() {
392                names.push(name.to_string());
393            }
394        }
395        let excluded_body = matches!(
396            node.kind(),
397            "function_definition" | "class_definition" | "lambda"
398        )
399        .then(|| node.child_by_field_name("body").map(|body| body.id()))
400        .flatten();
401        let mut cursor = node.walk();
402        pending.extend(
403            node.named_children(&mut cursor)
404                .filter(|child| Some(child.id()) != excluded_body),
405        );
406    }
407    names
408}
409
410fn setup_py_call_imported_function<'a>(
411    call: Node<'_>,
412    source: &str,
413    setup_bindings: &'a HashMap<Vec<String>, String>,
414) -> Option<&'a str> {
415    let mut function = call.child_by_field_name("function")?;
416    let mut path = Vec::new();
417    while function.kind() == "attribute" {
418        let attribute = function.child_by_field_name("attribute")?;
419        path.push(py_node_text(attribute, source).to_string());
420        let object = function.child_by_field_name("object")?;
421        function = object;
422    }
423    if function.kind() != "identifier" {
424        return None;
425    }
426    path.push(py_node_text(function, source).to_string());
427    path.reverse();
428    setup_bindings.get(&path).map(String::as_str)
429}
430
431fn setup_py_import_root_from_call(
432    call: Node<'_>,
433    source: &str,
434    setup_bindings: &HashMap<Vec<String>, String>,
435) -> Option<PathBuf> {
436    let arguments = call.child_by_field_name("arguments")?;
437    if arguments.kind() != "argument_list" {
438        return None;
439    }
440
441    let mut packages = None;
442    let mut package_dir = None;
443    let mut cursor = arguments.walk();
444    for argument in arguments.named_children(&mut cursor) {
445        if argument.kind() == "comment" {
446            continue;
447        }
448        // Positional dictionaries and expansions can supply packaging options.
449        if argument.kind() != "keyword_argument" {
450            return None;
451        }
452        let name = argument.child_by_field_name("name")?;
453        let value = argument.child_by_field_name("value")?;
454        match py_node_text(name, source).trim() {
455            "packages" if packages.is_none() => packages = Some(value),
456            "packages" => return None,
457            "package_dir" if package_dir.is_none() => package_dir = Some(value),
458            "package_dir" => return None,
459            _ => {}
460        }
461    }
462
463    let packages = packages?;
464    if let Some(package_dir) = package_dir {
465        return setup_py_static_package_dir(package_dir, source);
466    }
467    if setup_py_nonempty_literal_packages(packages, source) {
468        return Some(PathBuf::new());
469    }
470    setup_py_discovery_root_from_call(packages, source, setup_bindings)
471}
472
473fn setup_py_discovery_root_from_call(
474    call: Node<'_>,
475    source: &str,
476    setup_bindings: &HashMap<Vec<String>, String>,
477) -> Option<PathBuf> {
478    let function = setup_py_call_imported_function(call, source, setup_bindings)?;
479    if !matches!(function, "find_packages" | "find_namespace_packages") {
480        return None;
481    }
482    let arguments = call.child_by_field_name("arguments")?;
483    if arguments.kind() != "argument_list" {
484        return None;
485    }
486
487    let mut where_value = None;
488    let mut seen_exclude = false;
489    let mut seen_include = false;
490    let mut positional_index = 0;
491    let mut cursor = arguments.walk();
492    for argument in arguments.named_children(&mut cursor) {
493        if argument.kind() == "comment" {
494            continue;
495        }
496        if matches!(argument.kind(), "list_splat" | "dictionary_splat") {
497            return None;
498        }
499        if argument.kind() == "keyword_argument" {
500            let name = py_node_text(argument.child_by_field_name("name")?, source).trim();
501            let value = argument.child_by_field_name("value")?;
502            match name {
503                "where" if where_value.is_none() => where_value = Some(value),
504                "where" => return None,
505                "exclude" if !seen_exclude => seen_exclude = true,
506                "exclude" => return None,
507                "include" if !seen_include => seen_include = true,
508                "include" => return None,
509                _ => return None,
510            }
511            continue;
512        }
513
514        let slot = positional_index;
515        positional_index += 1;
516        match slot {
517            0 if where_value.is_none() => where_value = Some(argument),
518            0 => return None,
519            1 if !seen_exclude => seen_exclude = true,
520            1 => return None,
521            2 if !seen_include => seen_include = true,
522            2 => return None,
523            _ => return None,
524        }
525    }
526
527    where_value
528        .map(|value| python_plain_string_literal(value, source))
529        .unwrap_or(Some(""))
530        .map(PathBuf::from)
531}
532
533fn setup_py_nonempty_literal_packages(value: Node<'_>, source: &str) -> bool {
534    let value = setup_py_unwrap_parenthesized(value);
535    if !matches!(value.kind(), "list" | "set" | "tuple") {
536        return false;
537    }
538    let mut cursor = value.walk();
539    let mut nonempty = false;
540    for element in value.named_children(&mut cursor) {
541        if element.kind() == "comment" {
542            continue;
543        }
544        let Some(package) = python_plain_string_literal(element, source) else {
545            return false;
546        };
547        if package.is_empty() {
548            return false;
549        }
550        nonempty = true;
551    }
552    nonempty
553}
554
555fn setup_py_static_package_dir(value: Node<'_>, source: &str) -> Option<PathBuf> {
556    let value = setup_py_unwrap_parenthesized(value);
557    if value.kind() != "dictionary" {
558        return None;
559    }
560    let mut cursor = value.walk();
561    let mut pairs = value
562        .named_children(&mut cursor)
563        .filter(|node| node.kind() != "comment");
564    let Some(pair) = pairs.next() else {
565        return Some(PathBuf::new());
566    };
567    if pairs.next().is_some() || pair.kind() != "pair" {
568        return None;
569    }
570    let key = python_plain_string_literal(pair.child_by_field_name("key")?, source)?;
571    if !key.is_empty() {
572        return None;
573    }
574    let root = python_plain_string_literal(pair.child_by_field_name("value")?, source)?;
575    let root = PathBuf::from(root);
576    (!root.is_absolute()).then_some(root)
577}
578
579fn setup_py_unwrap_parenthesized(mut node: Node<'_>) -> Node<'_> {
580    while node.kind() == "parenthesized_expression" && node.named_child_count() == 1 {
581        node = node.named_child(0).expect("parenthesized expression child");
582    }
583    node
584}
585
586fn parse_setuptools_where_entries(manifest: &Path) -> Vec<String> {
587    let Ok(source) = std::fs::read_to_string(manifest) else {
588        return Vec::new();
589    };
590    let Ok(document) = source.parse::<toml::Value>() else {
591        return Vec::new();
592    };
593    document
594        .get("tool")
595        .and_then(|tool| tool.get("setuptools"))
596        .and_then(|setuptools| setuptools.get("packages"))
597        .and_then(|packages| packages.get("find"))
598        .and_then(|find| find.get("where"))
599        .and_then(toml::Value::as_array)
600        .map(|entries| {
601            entries
602                .iter()
603                .filter_map(toml::Value::as_str)
604                .map(str::to_string)
605                .collect()
606        })
607        .unwrap_or_default()
608}
609
610fn path_components(path: &Path) -> Vec<String> {
611    path.components()
612        .map(|component| component.as_os_str().to_string_lossy().to_string())
613        .filter(|component| !component.is_empty())
614        .collect()
615}
616
617pub fn python_is_decorated_function_boundary(node: Node<'_>) -> bool {
618    if node.kind() != "decorated_definition" {
619        return false;
620    }
621    let mut cursor = node.walk();
622    node.named_children(&mut cursor)
623        .any(|child| child.kind() == "function_definition")
624}
625
626#[derive(Clone)]
627pub struct Scope {
628    kind: ScopeKind,
629    path: String,
630    /// The structured qualified name matching `path` (M1 dual representation;
631    /// see `.agents/plans/fqname-interned-segments.md`). Tracked independent of
632    /// whether this scope level was actually `capture`d as a `CodeUnit`, so a
633    /// nested class/function that IS captured can always extend an ancestor's
634    /// `fq` even when an intermediate scope level (e.g. a non-captured nested
635    /// function) has no `code_unit` of its own to read `.fq()` from.
636    fq: FqName,
637    code_unit: Option<CodeUnit>,
638    method_receiver: Option<String>,
639}
640
641#[derive(Clone, Copy, PartialEq, Eq)]
642enum ScopeKind {
643    Class,
644    Function,
645}
646
647pub struct PythonVisitor<'a> {
648    pub file: &'a ProjectFile,
649    pub source: &'a str,
650    pub package_name: &'a str,
651    module_fq: &'a FqName,
652    pub parsed: &'a mut ParsedFile,
653    pub module: Option<CodeUnit>,
654    pub overload_decorators: &'a PythonOverloadDecoratorBindings,
655}
656
657struct PythonContainer<'tree> {
658    node: Node<'tree>,
659    scope: Vec<Scope>,
660    module_control_depth: usize,
661}
662
663enum PythonWork<'tree> {
664    Container(PythonContainer<'tree>),
665    Statement {
666        node: Node<'tree>,
667        scope: Vec<Scope>,
668        module_control_depth: usize,
669    },
670}
671
672impl<'a> PythonVisitor<'a> {
673    pub fn visit_container(
674        &mut self,
675        node: Node<'_>,
676        scope: &[Scope],
677        module_control_depth: usize,
678    ) {
679        let mut stack = vec![PythonWork::Container(PythonContainer {
680            node,
681            scope: scope.to_vec(),
682            module_control_depth,
683        })];
684        while let Some(work) = stack.pop() {
685            match work {
686                PythonWork::Container(container) => {
687                    let mut cursor = container.node.walk();
688                    let children = container
689                        .node
690                        .named_children(&mut cursor)
691                        .collect::<Vec<_>>();
692                    for child in children.into_iter().rev() {
693                        stack.push(PythonWork::Statement {
694                            node: child,
695                            scope: container.scope.clone(),
696                            module_control_depth: container.module_control_depth,
697                        });
698                    }
699                }
700                PythonWork::Statement {
701                    node,
702                    scope,
703                    module_control_depth,
704                } => self.visit_statement(node, &scope, module_control_depth, &mut stack),
705            }
706        }
707    }
708
709    fn visit_statement<'tree>(
710        &mut self,
711        node: Node<'tree>,
712        scope: &[Scope],
713        module_control_depth: usize,
714        stack: &mut Vec<PythonWork<'tree>>,
715    ) {
716        match node.kind() {
717            "decorated_definition" => {
718                if let Some(definition) = node.child_by_field_name("definition") {
719                    self.visit_definition(
720                        definition,
721                        Some(node),
722                        scope,
723                        module_control_depth,
724                        stack,
725                    );
726                }
727            }
728            "class_definition" | "function_definition" => {
729                self.visit_definition(node, None, scope, module_control_depth, stack)
730            }
731            "expression_statement" => {
732                self.visit_expression_statement(node, scope, module_control_depth)
733            }
734            "import_statement" | "import_from_statement" => self.visit_import_statement(node),
735            "if_statement" | "try_statement" | "with_statement" | "for_statement"
736            | "while_statement" => {
737                let next_depth = if scope.is_empty() {
738                    module_control_depth + 1
739                } else {
740                    module_control_depth
741                };
742                stack.push(PythonWork::Container(PythonContainer {
743                    node,
744                    scope: scope.to_vec(),
745                    module_control_depth: next_depth,
746                }));
747            }
748            "elif_clause" | "else_clause" | "except_clause" | "finally_clause" => {
749                stack.push(PythonWork::Container(PythonContainer {
750                    node,
751                    scope: scope.to_vec(),
752                    module_control_depth,
753                }));
754            }
755            "block" | "module" => stack.push(PythonWork::Container(PythonContainer {
756                node,
757                scope: scope.to_vec(),
758                module_control_depth,
759            })),
760            _ => {}
761        }
762    }
763
764    fn visit_definition<'tree>(
765        &mut self,
766        definition: Node<'tree>,
767        wrapper: Option<Node<'tree>>,
768        scope: &[Scope],
769        module_control_depth: usize,
770        stack: &mut Vec<PythonWork<'tree>>,
771    ) {
772        match definition.kind() {
773            "class_definition" => self.visit_class_definition(
774                definition,
775                wrapper.unwrap_or(definition),
776                scope,
777                module_control_depth,
778                stack,
779            ),
780            "function_definition" => self.visit_function_definition(
781                definition,
782                wrapper.unwrap_or(definition),
783                scope,
784                module_control_depth,
785                stack,
786            ),
787            _ => {}
788        }
789    }
790
791    fn visit_class_definition<'tree>(
792        &mut self,
793        node: Node<'tree>,
794        range_node: Node<'tree>,
795        scope: &[Scope],
796        module_control_depth: usize,
797        stack: &mut Vec<PythonWork<'tree>>,
798    ) {
799        let Some(name_node) = node.child_by_field_name("name") else {
800            return;
801        };
802        let name = py_node_text(name_node, self.source).trim();
803        if name.is_empty() {
804            return;
805        }
806
807        let capture = !scope.is_empty() || module_control_depth <= 1;
808
809        let short_name = scope
810            .last()
811            .map(|parent| format!("{}${name}", parent.path))
812            .unwrap_or_else(|| name.to_string());
813        // A nested class (any parent scope, Class or Function) is always joined
814        // with a literal `$` in the legacy convention above, which is exactly
815        // what `SegmentKind::Nested` renders regardless of the preceding
816        // segment's kind; a top-level class has no parent and is a plain `Type`
817        // hanging off the module-path `Package` chain.
818        let fq = match scope.last() {
819            Some(parent) => parent
820                .fq
821                .clone()
822                .with_pushed(py_segment(name, SegmentKind::Nested)),
823            None => self
824                .module_fq
825                .clone()
826                .with_pushed(py_segment(name, SegmentKind::Type)),
827        };
828        let code_unit = CodeUnit::new_fq(
829            self.file.clone(),
830            CodeUnitType::Class,
831            self.package_name.to_string(),
832            short_name.clone(),
833            fq.clone(),
834        );
835        if capture {
836            self.parsed
837                .replace_code_unit(code_unit.clone(), range_node, self.source, None, None);
838            self.parsed.add_signature(
839                code_unit.clone(),
840                python_class_signature(range_node, self.source),
841            );
842            if let Some(module) = &self.module
843                && scope.is_empty()
844            {
845                self.parsed.add_child(module.clone(), code_unit.clone());
846            }
847            if let Some(parent) = scope.last()
848                && let Some(parent_cu) = &parent.code_unit
849            {
850                self.parsed.add_child(parent_cu.clone(), code_unit.clone());
851            }
852            self.parsed.set_raw_supertypes(
853                code_unit.clone(),
854                extract_python_supertypes(node, self.source),
855            );
856        }
857
858        let mut next_scope = scope.to_vec();
859        if capture {
860            next_scope.push(Scope {
861                kind: ScopeKind::Class,
862                path: short_name,
863                fq,
864                code_unit: Some(code_unit),
865                method_receiver: None,
866            });
867        }
868        if let Some(body) = node.child_by_field_name("body") {
869            stack.push(PythonWork::Container(PythonContainer {
870                node: body,
871                scope: next_scope,
872                module_control_depth,
873            }));
874        }
875    }
876
877    fn visit_function_definition<'tree>(
878        &mut self,
879        node: Node<'tree>,
880        range_node: Node<'tree>,
881        scope: &[Scope],
882        module_control_depth: usize,
883        stack: &mut Vec<PythonWork<'tree>>,
884    ) {
885        let Some(name_node) = node.child_by_field_name("name") else {
886            return;
887        };
888        let name = py_node_text(name_node, self.source).trim();
889        if name.is_empty() {
890            return;
891        }
892
893        let capture = !python_is_property_mutator(range_node, self.source)
894            && ((scope.is_empty() && module_control_depth <= 1)
895                || scope
896                    .last()
897                    .is_some_and(|parent| parent.kind == ScopeKind::Class));
898        let short_name = if let Some(parent) = scope.last() {
899            match parent.kind {
900                ScopeKind::Class => format!("{}.{}", parent.path, name),
901                ScopeKind::Function => format!("{}${name}", parent.path),
902            }
903        } else {
904            name.to_string()
905        };
906        // Mirrors `short_name` above segment-for-segment: a method owned
907        // directly by a class joins with `.` (`Member`), while a function
908        // nested under another function is a local/closure and joins with the
909        // literal `$` that `SegmentKind::Nested` renders.
910        let fq = if let Some(parent) = scope.last() {
911            match parent.kind {
912                ScopeKind::Class => parent
913                    .fq
914                    .clone()
915                    .with_pushed(py_segment(name, SegmentKind::Member)),
916                ScopeKind::Function => parent
917                    .fq
918                    .clone()
919                    .with_pushed(py_segment(name, SegmentKind::Nested)),
920            }
921        } else {
922            self.module_fq
923                .clone()
924                .with_pushed(py_segment(name, SegmentKind::Member))
925        };
926
927        if capture {
928            let code_unit_type = if python_function_has_decorator(node, self.source, "property") {
929                CodeUnitType::Field
930            } else {
931                CodeUnitType::Function
932            };
933            let signature = node
934                .child_by_field_name("parameters")
935                .map(|parameters| py_node_text(parameters, self.source).trim().to_string());
936            let code_unit = CodeUnit::with_signature_and_fq(
937                self.file.clone(),
938                code_unit_type,
939                self.package_name.to_string(),
940                short_name.clone(),
941                signature,
942                false,
943                fq.clone(),
944            );
945            self.parsed
946                .replace_code_unit(code_unit.clone(), range_node, self.source, None, None);
947            let signature = python_function_signature(range_node, self.source);
948            self.parsed.add_signature_with_metadata(
949                code_unit.clone(),
950                python_signature_metadata(signature, node, self.source).with_declaration_only(
951                    self.overload_decorators
952                        .decorates_as_overload(node, self.source),
953                ),
954            );
955            if let Some(module) = &self.module
956                && scope.is_empty()
957            {
958                self.parsed.add_child(module.clone(), code_unit.clone());
959            }
960            if let Some(parent) = scope.last()
961                && parent.kind == ScopeKind::Class
962                && let Some(parent_cu) = &parent.code_unit
963            {
964                self.parsed.add_child(parent_cu.clone(), code_unit.clone());
965            }
966            let scope_code_unit = Some(code_unit);
967            let mut next_scope = scope.to_vec();
968            next_scope.push(Scope {
969                kind: ScopeKind::Function,
970                path: short_name,
971                fq,
972                code_unit: scope_code_unit,
973                method_receiver: scope
974                    .last()
975                    .is_some_and(|parent| parent.kind == ScopeKind::Class)
976                    .then(|| python_instance_method_receiver_name(node, self.source))
977                    .flatten(),
978            });
979            if let Some(body) = node.child_by_field_name("body") {
980                stack.push(PythonWork::Container(PythonContainer {
981                    node: body,
982                    scope: next_scope,
983                    module_control_depth,
984                }));
985            }
986            return;
987        }
988
989        let mut next_scope = scope.to_vec();
990        next_scope.push(Scope {
991            kind: ScopeKind::Function,
992            path: short_name,
993            fq,
994            code_unit: None,
995            method_receiver: None,
996        });
997        if let Some(body) = node.child_by_field_name("body") {
998            stack.push(PythonWork::Container(PythonContainer {
999                node: body,
1000                scope: next_scope,
1001                module_control_depth,
1002            }));
1003        }
1004    }
1005
1006    fn visit_expression_statement(
1007        &mut self,
1008        node: Node<'_>,
1009        scope: &[Scope],
1010        module_control_depth: usize,
1011    ) {
1012        let Some(assignment) = node.named_child(0) else {
1013            return;
1014        };
1015        if assignment.kind() != "assignment" {
1016            return;
1017        }
1018        let targets = python_chained_assignment_targets(assignment);
1019        if targets.is_empty() {
1020            return;
1021        }
1022        for left in &targets {
1023            self.visit_instance_attribute_assignment(*left, scope);
1024        }
1025        let names = targets
1026            .iter()
1027            .flat_map(|left| collect_assigned_names(*left, self.source))
1028            .collect::<Vec<_>>();
1029        for name in names {
1030            let (short_name, fq) = if let Some(parent) = scope.last() {
1031                if parent.kind != ScopeKind::Class {
1032                    continue;
1033                }
1034                (
1035                    format!("{}.{}", parent.path, name),
1036                    parent
1037                        .fq
1038                        .clone()
1039                        .with_pushed(py_segment(&name, SegmentKind::Member)),
1040                )
1041            } else if module_control_depth <= 1 {
1042                (
1043                    name.clone(),
1044                    self.module_fq
1045                        .clone()
1046                        .with_pushed(py_segment(&name, SegmentKind::Member)),
1047                )
1048            } else {
1049                continue;
1050            };
1051            let code_unit = CodeUnit::new_fq(
1052                self.file.clone(),
1053                CodeUnitType::Field,
1054                self.package_name.to_string(),
1055                short_name,
1056                fq,
1057            );
1058            if scope
1059                .last()
1060                .is_some_and(|parent| parent.kind == ScopeKind::Class)
1061            {
1062                // Reassigning a class attribute does not mint a new logical
1063                // member. Preserve every physical binding range so class-body
1064                // references between assignments can select the active one.
1065                self.parsed
1066                    .add_code_unit(code_unit.clone(), node, self.source, None, None);
1067            } else {
1068                self.parsed
1069                    .replace_code_unit(code_unit.clone(), node, self.source, None, None);
1070            }
1071            self.parsed.add_signature(
1072                code_unit.clone(),
1073                py_node_text(node, self.source).trim().to_string(),
1074            );
1075            if let Some(module) = &self.module
1076                && scope.is_empty()
1077            {
1078                self.parsed.add_child(module.clone(), code_unit.clone());
1079            }
1080            if let Some(parent) = scope.last()
1081                && parent.kind == ScopeKind::Class
1082                && let Some(parent_cu) = &parent.code_unit
1083            {
1084                self.parsed.add_child(parent_cu.clone(), code_unit);
1085            }
1086        }
1087    }
1088
1089    fn visit_instance_attribute_assignment(&mut self, left: Node<'_>, scope: &[Scope]) {
1090        let Some(function) = scope
1091            .last()
1092            .filter(|scope| scope.kind == ScopeKind::Function)
1093        else {
1094            return;
1095        };
1096        let Some(receiver) = function.method_receiver.as_deref() else {
1097            return;
1098        };
1099        let Some(parent) = scope
1100            .get(scope.len().saturating_sub(2))
1101            .filter(|scope| scope.kind == ScopeKind::Class)
1102        else {
1103            return;
1104        };
1105        let Some(parent_cu) = parent.code_unit.clone() else {
1106            return;
1107        };
1108        for (name, node) in collect_self_assigned_attributes(left, self.source, receiver) {
1109            let code_unit = CodeUnit::new_fq(
1110                self.file.clone(),
1111                CodeUnitType::Field,
1112                self.package_name.to_string(),
1113                format!("{}.{}", parent.path, name),
1114                parent
1115                    .fq
1116                    .clone()
1117                    .with_pushed(py_segment(&name, SegmentKind::Member)),
1118            );
1119            if !self.parsed.contains_declaration(&code_unit) {
1120                self.parsed.replace_code_unit(
1121                    code_unit.clone(),
1122                    node,
1123                    self.source,
1124                    Some(parent_cu.clone()),
1125                    Some(parent_cu.clone()),
1126                );
1127            }
1128            self.parsed.add_signature(
1129                code_unit.clone(),
1130                py_node_text(left, self.source).trim().to_string(),
1131            );
1132        }
1133    }
1134
1135    fn visit_import_statement(&mut self, node: Node<'_>) {
1136        for info in python_import_infos_from_node(node, self.source) {
1137            self.parsed.imports.push(info);
1138        }
1139    }
1140}
1141
1142/// Build the [`ParsedFile`] for one Python source file: module unit, type
1143/// identifiers, and the declaration walk. `analyzer/python/adapter.rs`'s
1144/// `LanguageAdapter::parse_file` is the only caller.
1145pub fn parse_python_file(file: &ProjectFile, source: &str, tree: &Tree) -> ParsedFile {
1146    let module_components = python_module_components(file);
1147    let module_name = module_components.join(".");
1148    let module_fq = python_module_fq_from_components(&module_components);
1149    let mut parsed = ParsedFile::new(module_name.clone());
1150    let root = tree.root_node();
1151
1152    collect_python_identifiers(root, source, &mut parsed.type_identifiers);
1153
1154    let module_code_unit = module_code_unit_from_fq(file, &module_components, module_fq.clone());
1155    if let Some(module) = module_code_unit.clone() {
1156        parsed.add_code_unit(module, root, source, None, None);
1157    }
1158
1159    let overload_decorators = PythonOverloadDecoratorBindings::collect(root, source);
1160    let mut visitor = PythonVisitor {
1161        file,
1162        source,
1163        package_name: &module_name,
1164        module_fq: &module_fq,
1165        parsed: &mut parsed,
1166        module: module_code_unit,
1167        overload_decorators: &overload_decorators,
1168    };
1169    visitor.visit_container(root, &[], 0);
1170
1171    parsed
1172}
1173
1174pub fn py_node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
1175    brokk_bifrost_core::analyzer::common::node_source_text(node, source)
1176}
1177
1178pub fn python_module_name(file: &ProjectFile) -> String {
1179    python_module_components(file).join(".")
1180}
1181
1182pub fn module_code_unit(file: &ProjectFile, module_fq: &str) -> Option<CodeUnit> {
1183    if module_fq.is_empty() {
1184        return None;
1185    }
1186    let components = python_module_components(file);
1187    debug_assert_eq!(
1188        module_fq,
1189        components.join("."),
1190        "module_code_unit must be built from the file's path-derived Python module name"
1191    );
1192    let structured_fq = python_module_fq_from_components(&components);
1193    module_code_unit_from_fq(file, &components, structured_fq)
1194}
1195
1196fn module_code_unit_from_fq(
1197    file: &ProjectFile,
1198    components: &[String],
1199    structured_fq: FqName,
1200) -> Option<CodeUnit> {
1201    let (short_name, package_components) = components.split_last()?;
1202    let package_name = package_components.join(".");
1203    Some(CodeUnit::new_fq(
1204        file.clone(),
1205        CodeUnitType::Module,
1206        package_name,
1207        short_name.clone(),
1208        structured_fq,
1209    ))
1210}
1211
1212fn python_class_signature(node: Node<'_>, source: &str) -> String {
1213    python_header_with_decorators(node, source)
1214}
1215
1216fn python_function_signature(node: Node<'_>, source: &str) -> String {
1217    let header = python_header_with_decorators(node, source);
1218    if let Some((head, tail)) = header.rsplit_once('\n') {
1219        format!("{head}\n{tail} ...")
1220    } else {
1221        format!("{header} ...")
1222    }
1223}
1224
1225fn python_signature_metadata(signature: String, node: Node<'_>, source: &str) -> SignatureMetadata {
1226    let Some(parameters_node) = node.child_by_field_name("parameters") else {
1227        return SignatureMetadata::new(signature, Vec::new())
1228            .with_dispatch_extensibility(DispatchExtensibility::Open);
1229    };
1230    let parameter_text = py_node_text(parameters_node, source).trim();
1231    let Some(parameters_start) = signature.find(parameter_text) else {
1232        return SignatureMetadata::new(signature, Vec::new())
1233            .with_dispatch_extensibility(DispatchExtensibility::Open);
1234    };
1235    let parameters_end = parameters_start + parameter_text.len();
1236    let mut search_start = parameters_start;
1237    let parameters = python_parameter_label_nodes(parameters_node)
1238        .into_iter()
1239        .filter_map(|label_node| {
1240            let label = py_node_text(label_node, source).trim();
1241            if label.is_empty() || search_start > parameters_end {
1242                return None;
1243            }
1244            let haystack = signature.get(search_start..parameters_end)?;
1245            let relative_start = haystack.find(label)?;
1246            let start_byte = search_start + relative_start;
1247            let end_byte = start_byte + label.len();
1248            search_start = end_byte;
1249            Some(ParameterMetadata::new(label, start_byte, end_byte))
1250        })
1251        .collect();
1252    SignatureMetadata::new(signature, parameters)
1253        .with_dispatch_extensibility(DispatchExtensibility::Open)
1254}
1255
1256fn python_parameter_label_nodes(parameters_node: Node<'_>) -> Vec<Node<'_>> {
1257    let mut labels = Vec::new();
1258    let mut cursor = parameters_node.walk();
1259    for child in parameters_node.named_children(&mut cursor) {
1260        if let Some(label_node) = python_parameter_label_node(child) {
1261            labels.push(label_node);
1262        }
1263    }
1264    labels
1265}
1266
1267/// The identifier node that names one parameter's binding.
1268///
1269/// The grammar gives `default_parameter` and `typed_default_parameter` a
1270/// `name` field but gives `typed_parameter` and the two splat patterns none,
1271/// so a caller that reads only the field loses the binding name of every
1272/// annotated parameter. Every Python surface that names parameters reads them
1273/// through this function.
1274/// Which splat a Python formal parameter spells, looking through the
1275/// annotation wrapper.
1276///
1277/// `*args` is a `list_splat_pattern` and `**kwargs` a
1278/// `dictionary_splat_pattern`, but the grammar spells `*args: str` as a
1279/// `typed_parameter` that holds one, so a test on the parameter's own node kind
1280/// misses every annotated variadic. A parameter that misses it binds like an
1281/// ordinary formal: one positional actual each, and the rest spill onto the
1282/// formals that follow.
1283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1284pub enum PythonParameterSplat {
1285    /// `*args`: collects every remaining positional actual.
1286    Positional,
1287    /// `**kwargs`: collects every remaining keyword actual.
1288    Keyword,
1289}
1290
1291pub fn python_parameter_splat(parameter: Node<'_>) -> Option<PythonParameterSplat> {
1292    let splat = match parameter.kind() {
1293        kind @ ("list_splat_pattern" | "dictionary_splat_pattern") => kind,
1294        _ => {
1295            let mut cursor = parameter.walk();
1296            parameter
1297                .named_children(&mut cursor)
1298                .map(|child| child.kind())
1299                .find(|kind| matches!(*kind, "list_splat_pattern" | "dictionary_splat_pattern"))?
1300        }
1301    };
1302    match splat {
1303        "list_splat_pattern" => Some(PythonParameterSplat::Positional),
1304        "dictionary_splat_pattern" => Some(PythonParameterSplat::Keyword),
1305        _ => unreachable!("the splat kind was matched above"),
1306    }
1307}
1308
1309pub fn python_parameter_label_node(node: Node<'_>) -> Option<Node<'_>> {
1310    match node.kind() {
1311        "identifier" => Some(node),
1312        "typed_parameter"
1313        | "typed_default_parameter"
1314        | "default_parameter"
1315        | "list_splat_pattern"
1316        | "dictionary_splat_pattern"
1317        | "keyword_separator" => node.child_by_field_name("name").or_else(|| {
1318            let mut cursor = node.walk();
1319            node.named_children(&mut cursor)
1320                .find_map(python_parameter_label_node)
1321        }),
1322        _ => None,
1323    }
1324}
1325
1326fn python_is_property_mutator(node: Node<'_>, source: &str) -> bool {
1327    python_header_with_decorators(node, source)
1328        .lines()
1329        .map(str::trim)
1330        .filter(|line| line.starts_with('@'))
1331        .any(|decorator| decorator.ends_with(".setter") || decorator.ends_with(".deleter"))
1332}
1333
1334pub fn python_expanded_comment_start(source: &str, start_byte: usize) -> usize {
1335    let line_starts = compute_line_starts(source);
1336    let line_index = find_line_index_for_offset(&line_starts, start_byte);
1337
1338    let mut comment_start = start_byte;
1339    for line_idx in (0..line_index).rev() {
1340        let line_start = line_starts[line_idx];
1341        let line_end = line_starts
1342            .get(line_idx + 1)
1343            .copied()
1344            .unwrap_or(source.len());
1345        let line = &source[line_start..line_end];
1346        let trimmed = line.trim_start();
1347
1348        if trimmed.trim().is_empty() {
1349            continue;
1350        }
1351
1352        if trimmed.starts_with('#') {
1353            comment_start = line_start;
1354            continue;
1355        }
1356
1357        break;
1358    }
1359
1360    comment_start
1361}
1362
1363fn python_header_with_decorators(node: Node<'_>, source: &str) -> String {
1364    let raw = py_node_text(node, source);
1365    let lines: Vec<_> = raw
1366        .lines()
1367        .map(str::trim_end)
1368        .filter(|line| !line.trim().is_empty())
1369        .collect();
1370    let mut relevant = Vec::new();
1371    for line in lines {
1372        let trimmed = line.trim_start();
1373        if trimmed.starts_with('@')
1374            || trimmed.starts_with("def ")
1375            || trimmed.starts_with("async def ")
1376            || trimmed.starts_with("class ")
1377        {
1378            relevant.push(trimmed.to_string());
1379            if trimmed.starts_with("def ")
1380                || trimmed.starts_with("async def ")
1381                || trimmed.starts_with("class ")
1382            {
1383                break;
1384            }
1385        }
1386    }
1387    relevant.join("\n")
1388}
1389
1390/// Every positional base of a class, as the spelling the hierarchy resolver
1391/// should look up.
1392///
1393/// A base this function omits is indistinguishable from a class that has no
1394/// such base, so member lookup would treat an incompletely modeled hierarchy
1395/// as a complete one and prove a member absent that the base declares. Every
1396/// positional base therefore contributes a spelling:
1397///
1398/// * A dotted name is its own spelling.
1399/// * A subscripted base (`Base[T]`, `MutableMapping[str, Any]`) contributes
1400///   its generic origin, which is the class the runtime actually inherits.
1401/// * Any other positional base -- a call such as `namedtuple(...)`, an
1402///   unpacked base list, a conditional expression -- contributes its source
1403///   spelling. Resolution fails on it, and the caller reports an unresolved
1404///   base instead of a complete member list.
1405///
1406/// A keyword argument (`metaclass=`, and the arbitrary keywords
1407/// `__init_subclass__` accepts) is not a base and contributes nothing here.
1408fn extract_python_supertypes(node: Node<'_>, source: &str) -> Vec<String> {
1409    let Some(superclasses) = node.child_by_field_name("superclasses") else {
1410        return Vec::new();
1411    };
1412    let mut result = Vec::new();
1413    let mut cursor = superclasses.walk();
1414    for child in superclasses.named_children(&mut cursor) {
1415        if child.kind() == "keyword_argument" {
1416            continue;
1417        }
1418        let named = python_base_origin_node(child);
1419        let text = py_node_text(named, source).trim();
1420        if !text.is_empty() {
1421            result.push(text.to_string());
1422        }
1423    }
1424    result
1425}
1426
1427/// The node whose text names a base class: the value a subscripted base
1428/// applies its type arguments to, or the base expression itself.
1429///
1430/// Base spellings recorded by [`extract_python_supertypes`] are looked up
1431/// again against the class's own syntax, so both sides must reduce a base the
1432/// same way or a generic base stops matching the spelling it produced.
1433pub fn python_base_origin_node<'tree>(base: Node<'tree>) -> Node<'tree> {
1434    if base.kind() != "subscript" {
1435        return base;
1436    }
1437    let Some(value) = base.child_by_field_name("value") else {
1438        return base;
1439    };
1440    if matches!(value.kind(), "identifier" | "attribute") {
1441        value
1442    } else {
1443        base
1444    }
1445}
1446
1447fn collect_assigned_names(node: Node<'_>, source: &str) -> Vec<String> {
1448    let mut names = Vec::new();
1449    walk_named_tree_preorder(node, true, |node| {
1450        match node.kind() {
1451            // An attribute or subscript target (`foo.bar = …`, `foo[i] = …`)
1452            // mutates an existing object; it declares neither the receiver nor
1453            // the member as a name, so do not descend into it.
1454            "attribute" | "subscript" => WalkControl::SkipChildren,
1455            "identifier" => {
1456                let text = py_node_text(node, source).trim();
1457                if !text.is_empty() {
1458                    names.push(text.to_string());
1459                }
1460                WalkControl::Continue
1461            }
1462            _ => WalkControl::Continue,
1463        }
1464    });
1465    names
1466}
1467
1468fn collect_self_assigned_attributes<'tree>(
1469    node: Node<'tree>,
1470    source: &str,
1471    receiver_name: &str,
1472) -> Vec<(String, Node<'tree>)> {
1473    let mut attributes = Vec::new();
1474    collect_direct_self_assigned_attributes(node, source, receiver_name, &mut attributes);
1475    attributes
1476}
1477
1478fn collect_direct_self_assigned_attributes<'tree>(
1479    node: Node<'tree>,
1480    source: &str,
1481    receiver_name: &str,
1482    attributes: &mut Vec<(String, Node<'tree>)>,
1483) {
1484    match node.kind() {
1485        "attribute" => {
1486            let Some(object) = node.child_by_field_name("object") else {
1487                return;
1488            };
1489            if object.kind() != "identifier" || py_node_text(object, source).trim() != receiver_name
1490            {
1491                return;
1492            }
1493            let Some(attribute) = node.child_by_field_name("attribute") else {
1494                return;
1495            };
1496            let name = py_node_text(attribute, source).trim();
1497            if !name.is_empty() {
1498                attributes.push((name.to_string(), attribute));
1499            }
1500        }
1501        // The grammar spells an unpacking target three ways: a bare comma list
1502        // is a `pattern_list`, and parentheses or brackets around it make a
1503        // `tuple_pattern` or a `list_pattern`. Omitting the bracketed forms
1504        // dropped every attribute a multi-line unpacking assigns.
1505        "pattern_list"
1506        | "tuple_pattern"
1507        | "list_pattern"
1508        | "tuple"
1509        | "list"
1510        | "parenthesized_expression" => {
1511            let mut cursor = node.walk();
1512            for child in node.named_children(&mut cursor) {
1513                collect_direct_self_assigned_attributes(child, source, receiver_name, attributes);
1514            }
1515        }
1516        _ => {}
1517    }
1518}
1519
1520fn python_instance_method_receiver_name(node: Node<'_>, source: &str) -> Option<String> {
1521    if python_function_has_decorator(node, source, "staticmethod")
1522        || python_function_has_decorator(node, source, "classmethod")
1523    {
1524        return None;
1525    }
1526    python_first_parameter_name(node, source)
1527}
1528
1529fn python_function_has_decorator(node: Node<'_>, source: &str, decorator_name: &str) -> bool {
1530    let Some(parent) = node.parent() else {
1531        return false;
1532    };
1533    if parent.kind() != "decorated_definition" {
1534        return false;
1535    }
1536    let mut cursor = parent.walk();
1537    parent
1538        .named_children(&mut cursor)
1539        .filter(|child| child.kind() == "decorator")
1540        .filter_map(|decorator| decorator.named_child(0))
1541        .filter_map(expression_name_node)
1542        .any(|name| py_node_text(name, source).trim() == decorator_name)
1543}
1544
1545/// The name a callable binds its first parameter to, which for a method is
1546/// the receiver every `self.x` and `setattr(self, ...)` in its body names.
1547pub fn python_first_parameter_name(node: Node<'_>, source: &str) -> Option<String> {
1548    let parameters = node.child_by_field_name("parameters")?;
1549    let mut cursor = parameters.walk();
1550    parameters
1551        .named_children(&mut cursor)
1552        .find_map(|child| python_parameter_name(child, source))
1553}
1554
1555fn python_parameter_name(node: Node<'_>, source: &str) -> Option<String> {
1556    match node.kind() {
1557        "identifier" => Some(py_node_text(node, source).trim().to_string()),
1558        "typed_parameter"
1559        | "default_parameter"
1560        | "list_splat_pattern"
1561        | "dictionary_splat_pattern" => node
1562            .child_by_field_name("name")
1563            .or_else(|| {
1564                let mut cursor = node.walk();
1565                node.named_children(&mut cursor)
1566                    .find(|child| child.kind() == "identifier")
1567            })
1568            .and_then(|name| python_parameter_name(name, source)),
1569        _ => None,
1570    }
1571    .filter(|name| !name.is_empty())
1572}
1573
1574pub fn collect_python_identifiers(node: Node<'_>, source: &str, identifiers: &mut HashSet<String>) {
1575    walk_named_tree_preorder(node, true, |node| {
1576        if node.kind() == "identifier" {
1577            let text = py_node_text(node, source).trim();
1578            if !text.is_empty() {
1579                identifiers.insert(text.to_string());
1580            }
1581        }
1582        WalkControl::Continue
1583    });
1584}
1585
1586pub fn parse_python_tree(source: &str) -> Option<Tree> {
1587    let mut parser = Parser::new();
1588    parser
1589        .set_language(&tree_sitter_python::LANGUAGE.into())
1590        .expect("failed to load python parser");
1591    parser.parse(source, None)
1592}
1593
1594/// Every target a possibly chained assignment binds.
1595///
1596/// Python's `encrypt = decrypt = process` binds both names, but the grammar
1597/// spells it as one `assignment` whose `right` is another `assignment`. Reading
1598/// only the outermost `left` declares `encrypt` and silently drops `decrypt`,
1599/// so the alias reads as an absent member on the class that defines it.
1600fn python_chained_assignment_targets<'tree>(assignment: Node<'tree>) -> Vec<Node<'tree>> {
1601    let mut targets = Vec::new();
1602    let mut node = assignment;
1603    while let Some(left) = node.child_by_field_name("left") {
1604        targets.push(left);
1605        match node.child_by_field_name("right") {
1606            Some(right) if right.kind() == "assignment" => node = right,
1607            _ => break,
1608        }
1609    }
1610    targets
1611}
1612
1613#[cfg(test)]
1614mod supertype_tests {
1615    use super::extract_python_supertypes;
1616    use tree_sitter::{Node, Parser};
1617
1618    fn class_node<'tree>(tree: &'tree tree_sitter::Tree, source: &str) -> Node<'tree> {
1619        let mut cursor = tree.root_node().walk();
1620        tree.root_node()
1621            .named_children(&mut cursor)
1622            .find(|node| node.kind() == "class_definition")
1623            .unwrap_or_else(|| panic!("source declares a class: {source}"))
1624    }
1625
1626    fn parse(source: &str) -> tree_sitter::Tree {
1627        let mut parser = Parser::new();
1628        parser
1629            .set_language(&tree_sitter_python::LANGUAGE.into())
1630            .expect("the Python grammar loads");
1631        parser.parse(source, None).expect("the source parses")
1632    }
1633
1634    #[test]
1635    fn every_positional_base_contributes_a_spelling() {
1636        for (source, expected) in [
1637            ("class A(Base): pass\n", vec!["Base"]),
1638            ("class A(pkg.Base): pass\n", vec!["pkg.Base"]),
1639            // The generic origin is the class the runtime inherits; dropping
1640            // a subscripted base made an incomplete hierarchy look complete.
1641            ("class A(Base[int]): pass\n", vec!["Base"]),
1642            ("class A(pkg.Base[str, int]): pass\n", vec!["pkg.Base"]),
1643            (
1644                "class A(Mapping[str, Any], Base): pass\n",
1645                vec!["Mapping", "Base"],
1646            ),
1647            // Not a base: a keyword argument configures class creation.
1648            ("class A(Base, metaclass=Meta): pass\n", vec!["Base"]),
1649            ("class A(metaclass=Meta): pass\n", Vec::new()),
1650            // Unnameable bases still register, so resolution reports an
1651            // unresolved base rather than a complete member list.
1652            (
1653                "class A(namedtuple(\"P\", \"x\")): pass\n",
1654                vec!["namedtuple(\"P\", \"x\")"],
1655            ),
1656            ("class A(*bases): pass\n", vec!["*bases"]),
1657            ("class A: pass\n", Vec::new()),
1658        ] {
1659            let tree = parse(source);
1660            let node = class_node(&tree, source);
1661            assert_eq!(
1662                extract_python_supertypes(node, source),
1663                expected,
1664                "supertypes of {source}"
1665            );
1666        }
1667    }
1668}