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