Skip to main content

brokk_bifrost_python/
structural.rs

1//! Python structural spec: maps tree-sitter-python node types onto the
2//! normalized kind vocabulary and extracts role edges from AST fields.
3//! See `src/analyzer/structural/spec.rs` for the contract and
4//! `.agent/ISSUE_328_SEARCH_AST_EXECPLAN.md` for the design.
5
6use brokk_bifrost_core::analyzer::common::node_source_text;
7use brokk_bifrost_core::analyzer::structural::adapter_helpers::{
8    attach_argument_role_with_derived_name, attach_role_with_derived_name, attach_terminal_callee,
9    field_name_in_parent, first_named_child, nearest_ancestor, node_range,
10};
11use brokk_bifrost_core::analyzer::structural::callable::CallSiteContext;
12use brokk_bifrost_core::analyzer::structural::edges::{
13    DEEP_REFERENCE_EDGE_SUPPORT, ReferenceEdgeSupport,
14};
15use brokk_bifrost_core::analyzer::structural::kinds::{NormalizedKind, Role};
16use brokk_bifrost_core::analyzer::structural::materialization::{
17    DeclarationMaterializationSupport, PYTHON_MATERIALIZATION_SUPPORT,
18};
19use brokk_bifrost_core::analyzer::structural::occurrences::{
20    Namespace, OccurrenceRole, OccurrenceRoleSupport, default_occurrence_namespace,
21};
22use brokk_bifrost_core::analyzer::structural::resolution::{
23    BindingActivation, BindingKind, DEEP_LEXICAL_ENVIRONMENT_SUPPORT, HoistingClass,
24    LexicalEnvironmentSupport,
25};
26use brokk_bifrost_core::analyzer::structural::routes::{
27    CuratedExportSurface, DEEP_IDENTITY_AXES, IdentityRouteSupport, RouteHopKind,
28};
29use brokk_bifrost_core::analyzer::structural::spec::{EmbeddedLeafFact, RoleSink, StructuralSpec};
30use brokk_bifrost_core::analyzer::{Language, Range};
31use brokk_bifrost_core::cancellation::CancellationToken;
32use brokk_bifrost_core::hash::HashSet;
33use tree_sitter::Node;
34
35use crate::syntax::{
36    expression_name_node, python_deferred_annotation_identifier_ranges,
37    python_keyword_argument_label, python_node_is_in_annotation, python_plain_string_literal,
38};
39
40#[derive(Debug, Default)]
41pub struct PythonStructuralSpec;
42
43pub static PYTHON_STRUCTURAL_SPEC: PythonStructuralSpec = PythonStructuralSpec;
44
45/// Grammar node-type → normalized kind. Every name here must exist in the
46/// tree-sitter-python grammar; `tests::python_kind_table_matches_grammar`
47/// asserts that, so a grammar bump that renames a node fails loudly.
48pub const PYTHON_KIND_TABLE: &[(&str, NormalizedKind)] = &[
49    ("call", NormalizedKind::Call),
50    ("attribute", NormalizedKind::FieldAccess),
51    ("function_definition", NormalizedKind::Function),
52    ("lambda", NormalizedKind::Lambda),
53    ("class_definition", NormalizedKind::Class),
54    ("assignment", NormalizedKind::Assignment),
55    ("import_statement", NormalizedKind::Import),
56    ("import_from_statement", NormalizedKind::Import),
57    ("identifier", NormalizedKind::Identifier),
58    ("string", NormalizedKind::StringLiteral),
59    ("concatenated_string", NormalizedKind::StringLiteral),
60    ("integer", NormalizedKind::NumericLiteral),
61    ("float", NormalizedKind::NumericLiteral),
62    ("true", NormalizedKind::BooleanLiteral),
63    ("false", NormalizedKind::BooleanLiteral),
64    ("none", NormalizedKind::NullLiteral),
65    ("return_statement", NormalizedKind::Return),
66    ("raise_statement", NormalizedKind::Throw),
67    ("except_clause", NormalizedKind::Catch),
68    ("if_statement", NormalizedKind::If),
69    ("for_statement", NormalizedKind::ForLoop),
70    ("list", NormalizedKind::CollectionLiteral),
71    ("set", NormalizedKind::CollectionLiteral),
72    ("dictionary", NormalizedKind::CollectionLiteral),
73    ("tuple", NormalizedKind::CollectionLiteral),
74    ("while_statement", NormalizedKind::WhileLoop),
75    // Python's indented suite. The module node is deliberately absent: a file
76    // scope is not a statement list nested inside another one, and making the
77    // root a fact in one language only would give Python a scope shape no
78    // other adapter has.
79    ("block", NormalizedKind::Block),
80    ("decorator", NormalizedKind::Decorator),
81];
82
83/// Attach `decorators` edges for a definition wrapped in Python's
84/// `decorated_definition` node (which itself is not normalized).
85fn attach_decorators(sink: &mut RoleSink<'_>, definition: Node<'_>) {
86    let Some(parent) = definition.parent() else {
87        return;
88    };
89    if parent.kind() != "decorated_definition" {
90        return;
91    }
92    for index in 0..parent.named_child_count() {
93        let Some(child) = parent.named_child(index) else {
94            continue;
95        };
96        if child.kind() == "decorator" {
97            attach_role_with_derived_name(sink, Role::Decorator, child, expression_name_node);
98        }
99    }
100}
101
102static PYTHON_OCCURRENCE_ROLE_SUPPORT: OccurrenceRoleSupport = OccurrenceRoleSupport::NONE
103    .supported(OccurrenceRole::DeclarationName)
104    .supported(OccurrenceRole::Binder)
105    .supported(OccurrenceRole::LabelOrKey)
106    .supported(OccurrenceRole::TypeOperand)
107    .supported(OccurrenceRole::PathSegment)
108    .supported(OccurrenceRole::ImportAlias)
109    .supported(OccurrenceRole::ImportTarget)
110    .supported(OccurrenceRole::ReceiverPosition)
111    .supported(OccurrenceRole::MemberPosition)
112    .supported(OccurrenceRole::ValueReference);
113
114/// Whether a `dotted_name` names an imported module rather than an ordinary
115/// attribute chain, which decides whether its tail is an import target.
116///
117/// `relative_import` is the wrapper the grammar puts around the module name of
118/// `from .impl import x`. It is the same import target as the `pkg.impl` of
119/// `from pkg.impl import x`, so it passes through like the other wrappers.
120fn python_dotted_name_is_import(dotted_name: Node<'_>) -> bool {
121    let mut current = dotted_name;
122    loop {
123        let Some(parent) = current.parent() else {
124            return false;
125        };
126        match parent.kind() {
127            "import_statement" | "import_from_statement" | "future_import_statement" => {
128                return true;
129            }
130            "aliased_import" | "dotted_name" | "relative_import" => current = parent,
131            _ => return false,
132        }
133    }
134}
135
136/// Classify one Python identifier token by its AST position.
137///
138/// Python has no separate type-identifier node, so annotation operands are
139/// recognized by their enclosing `type` node — the same field the parser uses
140/// to separate `def f(x: T)`'s binder from its annotation.
141fn python_occurrence_role(node: Node<'_>) -> Option<OccurrenceRole> {
142    if node.kind() != "identifier" {
143        return None;
144    }
145    let parent = node.parent()?;
146    let field = field_name_in_parent(parent, node);
147    let role = match parent.kind() {
148        "function_definition" | "class_definition" if field == Some("name") => {
149            OccurrenceRole::DeclarationName
150        }
151        // Every annotation, return type and type-alias operand is wrapped in a
152        // `type` node; `generic_type`/`type_parameter` nest inside one.
153        "type" | "generic_type" | "type_parameter" | "constrained_type" | "union_type" => {
154            OccurrenceRole::TypeOperand
155        }
156        "parameters"
157        | "lambda_parameters"
158        | "typed_parameter"
159        | "list_splat_pattern"
160        | "dictionary_splat_pattern"
161        | "tuple_pattern"
162        | "list_pattern"
163        | "pattern_list"
164        | "as_pattern_target" => OccurrenceRole::Binder,
165        "default_parameter" | "typed_default_parameter" if field == Some("name") => {
166            OccurrenceRole::Binder
167        }
168        "for_statement" | "for_in_clause" if field == Some("left") => OccurrenceRole::Binder,
169        "keyword_argument" if python_keyword_argument_label(node) => OccurrenceRole::LabelOrKey,
170        "attribute" => match field {
171            Some("attribute") => OccurrenceRole::MemberPosition,
172            Some("object") => OccurrenceRole::ReceiverPosition,
173            _ => OccurrenceRole::ValueReference,
174        },
175        "aliased_import" if field == Some("alias") => OccurrenceRole::ImportAlias,
176        "import_from_statement" if field == Some("name") => OccurrenceRole::ImportTarget,
177        "dotted_name" => {
178            let is_tail =
179                parent.named_child(parent.named_child_count().saturating_sub(1)) == Some(node);
180            match (is_tail, python_dotted_name_is_import(parent)) {
181                (true, true) => OccurrenceRole::ImportTarget,
182                (true, false) => OccurrenceRole::ValueReference,
183                (false, _) => OccurrenceRole::PathSegment,
184            }
185        }
186        _ => OccurrenceRole::ValueReference,
187    };
188    Some(role)
189}
190
191/// Whether a `def` declares a method, that is, whether the suite it sits in
192/// belongs to a `class_definition`.
193///
194/// This reads the parse tree rather than the nearest enclosing normalized kind
195/// because the suite between a class and its methods is itself a normalized
196/// node now (`NormalizedKind::Block`, issue #1474). Walking the concrete
197/// ancestors is also the more direct statement of the rule: a nested `def`
198/// inside a method reaches its enclosing `function_definition` first and stays
199/// a function.
200fn python_definition_is_method(definition: Node<'_>) -> bool {
201    let mut current = definition;
202    while let Some(parent) = current.parent() {
203        match parent.kind() {
204            "class_definition" => return true,
205            // The suite that holds the members, and the wrapper a decorated
206            // definition sits in, are pass-through on the way to the owner.
207            "block" | "decorated_definition" => current = parent,
208            _ => return false,
209        }
210    }
211    false
212}
213
214/// The binding one Python binder token introduces, and the interval it is in
215/// effect over.
216///
217/// Python's function locals are scope-categorical rather than positional: a
218/// name assigned anywhere in a function body is a local of that function for
219/// the whole body, which is why a read above the assignment is an
220/// `UnboundLocalError` rather than a read of an outer name. That is exactly
221/// `ScopeWide`. The one positional exception is a comprehension target, which
222/// lives in the comprehension's own implicit scope; the same exception
223/// `analyzer::python::bindings` records as `PythonComprehensionBinding`.
224fn python_binding_activation(binder: Node<'_>, scope: Range) -> Option<BindingActivation> {
225    // A binder outside every form below is an ordinary assignment target --
226    // the members of a module-level or block-level `a, b = ...` pattern list,
227    // for example -- and Python's scope-categorical rule makes it a local of
228    // its declaring scope for the whole scope. Answering `None` here made the
229    // whole file's lexical environment incomplete as soon as one such binder
230    // existed (scripts/test-cost/greedy.py's `s, k = heapq.heappop(heap)` at
231    // module scope), which failed every code-smells gate that derived it.
232    let Some(form) = nearest_ancestor(binder, |kind| {
233        matches!(
234            kind,
235            "parameters"
236                | "lambda_parameters"
237                | "for_statement"
238                | "for_in_clause"
239                | "as_pattern"
240                | "list_comprehension"
241                | "set_comprehension"
242                | "dictionary_comprehension"
243                | "generator_expression"
244                | "function_definition"
245        )
246    }) else {
247        return Some(BindingActivation {
248            kind: BindingKind::Local,
249            hoisting: HoistingClass::ScopeWide,
250            activation: scope,
251        });
252    };
253    match form.kind() {
254        "parameters" | "lambda_parameters" | "function_definition" => Some(BindingActivation {
255            kind: BindingKind::Parameter,
256            hoisting: HoistingClass::ScopeWide,
257            activation: scope,
258        }),
259        "for_in_clause" => {
260            // A comprehension clause binds only inside the comprehension.
261            let comprehension = nearest_ancestor(form, |kind| {
262                matches!(
263                    kind,
264                    "list_comprehension"
265                        | "set_comprehension"
266                        | "dictionary_comprehension"
267                        | "generator_expression"
268                )
269            })?;
270            Some(BindingActivation {
271                kind: BindingKind::LoopVariable,
272                hoisting: HoistingClass::DeclaredHead,
273                activation: node_range(comprehension),
274            })
275        }
276        "for_statement" => Some(BindingActivation {
277            kind: BindingKind::LoopVariable,
278            hoisting: HoistingClass::ScopeWide,
279            activation: scope,
280        }),
281        "as_pattern" => Some(BindingActivation {
282            kind: BindingKind::PatternBinder,
283            hoisting: HoistingClass::ScopeWide,
284            activation: scope,
285        }),
286        _ => Some(BindingActivation {
287            kind: BindingKind::Local,
288            hoisting: HoistingClass::ScopeWide,
289            activation: scope,
290        }),
291    }
292}
293
294/// The text one plain string literal denotes, or `None` when the literal is
295/// not plain: an f-string interpolation, an escape sequence this reader does
296/// not decode, or an implicit concatenation. `None` is never an empty name --
297/// it is "this value is computed", which makes the whole surface unreadable.
298/// Whether every member of a curated surface was read from the parse tree.
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300enum ReadableSurface {
301    Yes,
302    No,
303}
304
305/// Collect the members of an `__all__` value into `names`, reporting whether
306/// every member was readable. Only a list or tuple display of plain string
307/// literals is; anything else is a value the source computes.
308fn python_collect_all_members(
309    value: Node<'_>,
310    source: &str,
311    names: &mut HashSet<String>,
312) -> ReadableSurface {
313    if !matches!(value.kind(), "list" | "tuple") {
314        return ReadableSurface::No;
315    }
316    let mut cursor = value.walk();
317    for element in value.named_children(&mut cursor) {
318        match python_plain_string_literal(element, source) {
319            Some(text) => {
320                names.insert(text.to_owned());
321            }
322            None => return ReadableSurface::No,
323        }
324    }
325    ReadableSurface::Yes
326}
327
328/// The names a Python module curates as its public surface: the value of its
329/// module-level `__all__`.
330///
331/// Only the module's own statements are read. An `__all__` inside a function
332/// is a local rather than the module's surface, and a name the module binds
333/// conditionally is still bound by the statement this reader already sees.
334/// A statement that assigns, extends, or mutates `__all__` with anything but a
335/// list or tuple of plain string literals makes the surface unreadable: the
336/// members are then unknown, and no import is classified from them.
337fn python_curated_export_surface(root: Node<'_>, source: &str) -> CuratedExportSurface {
338    let names_all = |node: Option<Node<'_>>| {
339        node.is_some_and(|node| {
340            node.kind() == "identifier" && node_source_text(node, source) == "__all__"
341        })
342    };
343    let mut names: HashSet<String> = HashSet::default();
344    let mut stated = false;
345    let mut readable = ReadableSurface::Yes;
346    let mut cursor = root.walk();
347    for statement in root.named_children(&mut cursor) {
348        if statement.kind() != "expression_statement" {
349            continue;
350        }
351        let mut inner = statement.walk();
352        for expression in statement.named_children(&mut inner) {
353            match expression.kind() {
354                "assignment" | "augmented_assignment" => {
355                    if !names_all(expression.child_by_field_name("left")) {
356                        continue;
357                    }
358                    stated = true;
359                    // An annotation without a value (`__all__: list[str]`)
360                    // binds nothing, so it states no members either way.
361                    let Some(value) = expression.child_by_field_name("right") else {
362                        continue;
363                    };
364                    // `+=` extends the surface; every other augmentation is a
365                    // value this reader does not compute.
366                    let extends = expression.kind() == "assignment"
367                        || expression
368                            .child_by_field_name("operator")
369                            .is_some_and(|operator| operator.kind() == "+=");
370                    if !extends
371                        || python_collect_all_members(value, source, &mut names)
372                            == ReadableSurface::No
373                    {
374                        readable = ReadableSurface::No;
375                    }
376                }
377                // `__all__.extend(other)` and its siblings rewrite the surface
378                // from a value the reader cannot see.
379                "call" => {
380                    let Some(function) = expression.child_by_field_name("function") else {
381                        continue;
382                    };
383                    if function.kind() == "attribute"
384                        && names_all(function.child_by_field_name("object"))
385                    {
386                        stated = true;
387                        readable = ReadableSurface::No;
388                    }
389                }
390                _ => {}
391            }
392        }
393    }
394    match (stated, readable) {
395        (false, _) => CuratedExportSurface::Absent,
396        (true, ReadableSurface::Yes) => CuratedExportSurface::Listed(names),
397        (true, ReadableSurface::No) => CuratedExportSurface::Unreadable,
398    }
399}
400
401/// Which indirection relation one Python import token participates in.
402///
403/// Python's grammar names no re-export, so the relation follows the explicit
404/// re-export rules the typing ecosystem already enforces (PEP 484 stub
405/// semantics, applied by pyright and mypy in strict mode), which makes this
406/// relation agree with what a type checker calls public:
407///
408/// 1. A name on the module's `__all__` is a re-export of whatever binding the
409///    module gives that name.
410/// 2. The redundant-alias forms `from x import y as y` and `import x as x`
411///    are re-exports.
412/// 3. `from x import *` is one star hop that forwards the public surface of
413///    `x`; the expansion is the import machinery's work, not this producer's,
414///    so the hop is recorded on the module reference and nothing is
415///    enumerated here.
416/// 4. Every other import is an ordinary import, including a plain
417///    `from .impl import helper` in a package `__init__.py` that states no
418///    `__all__`. The facade convention alone does not make a name public, and
419///    a consumer that wants every name a facade imports already has the
420///    import relation.
421///
422/// `None` is the answer for a name whose membership only an unreadable
423/// `__all__` could settle; the file's relations then report incomplete rather
424/// than guessing either way.
425fn python_indirection_relation(
426    token: Node<'_>,
427    source: &str,
428    surface: &CuratedExportSurface,
429) -> Option<RouteHopKind> {
430    let statement = nearest_ancestor(token, |kind| {
431        matches!(
432            kind,
433            "import_statement" | "import_from_statement" | "future_import_statement"
434        )
435    })?;
436    // The statement's own child that holds this token: a `module_name` field,
437    // or one `name` field of the import list.
438    let mut clause = token;
439    while let Some(parent) = clause.parent() {
440        if parent.id() == statement.id() {
441            break;
442        }
443        clause = parent;
444    }
445
446    if field_name_in_parent(statement, clause) == Some("module_name") {
447        // `from x import a` binds `a`, not `x`, so the module reference
448        // forwards nothing -- unless the import is the star form, whose one
449        // hop forwards the whole surface of `x`.
450        let mut cursor = statement.walk();
451        let star = statement
452            .children(&mut cursor)
453            .any(|child| child.kind() == "wildcard_import");
454        return Some(if star {
455            RouteHopKind::ReExport
456        } else {
457            RouteHopKind::Import
458        });
459    }
460
461    let bound = match clause.kind() {
462        "aliased_import" => {
463            let name = clause.child_by_field_name("name")?;
464            let alias = clause.child_by_field_name("alias")?;
465            if node_source_text(name, source) == node_source_text(alias, source) {
466                return Some(RouteHopKind::ReExport);
467            }
468            alias
469        }
470        // `import a.b.c` binds the top package `a`; `from m import a` binds
471        // the single-segment name the import list spells.
472        "dotted_name" if statement.kind() == "import_statement" => clause.named_child(0)?,
473        "dotted_name" => clause,
474        _ => return None,
475    };
476    match surface.lists(node_source_text(bound, source)) {
477        Some(true) => Some(RouteHopKind::ReExport),
478        Some(false) => Some(RouteHopKind::Import),
479        None => None,
480    }
481}
482
483impl StructuralSpec for PythonStructuralSpec {
484    fn language(&self) -> Language {
485        Language::Python
486    }
487
488    fn supports_boolean_literal_value(&self) -> bool {
489        true
490    }
491
492    fn reference_edge_support(&self) -> &ReferenceEdgeSupport {
493        &DEEP_REFERENCE_EDGE_SUPPORT
494    }
495
496    fn identity_route_support(&self) -> &IdentityRouteSupport {
497        // `import x as y` is an alias, and `python_indirection_relation`
498        // states which imports re-export (issue #1649).
499        static SUPPORT: IdentityRouteSupport = DEEP_IDENTITY_AXES
500            .supported_relation(RouteHopKind::Alias)
501            .supported_relation(RouteHopKind::Import)
502            .supported_relation(RouteHopKind::ReExport)
503            .supported_relation(RouteHopKind::NestedOwner);
504        &SUPPORT
505    }
506
507    /// Python's one qualified-path chain is `dotted_name`, which is flat
508    /// rather than left-nested: its named children are the segments in order.
509    fn qualified_path_root<'tree>(&self, token: Node<'tree>) -> Option<Node<'tree>> {
510        if token.kind() != "identifier" {
511            return None;
512        }
513        token
514            .parent()
515            .filter(|parent| parent.kind() == "dotted_name")
516    }
517
518    fn path_segment_tokens<'tree>(&self, root: Node<'tree>) -> Vec<Node<'tree>> {
519        if root.kind() != "dotted_name" {
520            return Vec::new();
521        }
522        let mut cursor = root.walk();
523        root.named_children(&mut cursor)
524            .filter(|child| child.kind() == "identifier")
525            .collect()
526    }
527
528    fn curated_export_surface(&self, root: Node<'_>, source: &str) -> CuratedExportSurface {
529        python_curated_export_surface(root, source)
530    }
531
532    fn indirection_relation(
533        &self,
534        token: Node<'_>,
535        source: &str,
536        surface: &CuratedExportSurface,
537    ) -> Option<RouteHopKind> {
538        python_indirection_relation(token, source, surface)
539    }
540
541    fn kind_table(&self) -> &'static [(&'static str, NormalizedKind)] {
542        PYTHON_KIND_TABLE
543    }
544
545    fn refine_kind(
546        &self,
547        node: Node<'_>,
548        kind: NormalizedKind,
549        _enclosing: Option<NormalizedKind>,
550        _source: &str,
551        _context: &CallSiteContext,
552    ) -> NormalizedKind {
553        if kind == NormalizedKind::Function && python_definition_is_method(node) {
554            NormalizedKind::Method
555        } else {
556            kind
557        }
558    }
559
560    fn should_extract(&self, node: Node<'_>, kind: NormalizedKind) -> bool {
561        kind != NormalizedKind::Assignment || node.child_by_field_name("right").is_some()
562    }
563
564    fn supports_kind(&self, kind: NormalizedKind) -> bool {
565        kind == NormalizedKind::Method
566            || self
567                .kind_table()
568                .iter()
569                .any(|(_, fact_kind)| fact_kind.satisfies(kind))
570    }
571
572    fn occurrence_role_support(&self) -> &OccurrenceRoleSupport {
573        &PYTHON_OCCURRENCE_ROLE_SUPPORT
574    }
575
576    fn lexical_environment_support(&self) -> &LexicalEnvironmentSupport {
577        &DEEP_LEXICAL_ENVIRONMENT_SUPPORT
578    }
579
580    fn materialization_support(&self) -> &DeclarationMaterializationSupport {
581        &PYTHON_MATERIALIZATION_SUPPORT
582    }
583
584    fn binding_activation(&self, binder: Node<'_>, scope: Range) -> Option<BindingActivation> {
585        python_binding_activation(binder, scope)
586    }
587
588    /// Python only classifies a scope segment inside a `dotted_name`, and every
589    /// non-tail segment of a dotted name is a module.
590    fn occurrence_namespace(
591        &self,
592        role: OccurrenceRole,
593        declares: Option<NormalizedKind>,
594    ) -> Option<Namespace> {
595        match role {
596            OccurrenceRole::PathSegment => Some(Namespace::Module),
597            _ => default_occurrence_namespace(role, declares),
598        }
599    }
600
601    fn embedded_leaf_facts(
602        &self,
603        node: Node<'_>,
604        kind: NormalizedKind,
605        source: &str,
606        cancellation: Option<&CancellationToken>,
607    ) -> Vec<EmbeddedLeafFact> {
608        if kind != NormalizedKind::StringLiteral
609            || node.kind() != "string"
610            || !python_node_is_in_annotation(node)
611        {
612            return Vec::new();
613        }
614
615        python_deferred_annotation_identifier_ranges(node, source, cancellation)
616            .unwrap_or_default()
617            .into_iter()
618            .map(|range| EmbeddedLeafFact {
619                kind: NormalizedKind::Identifier,
620                range,
621                occurrence_role: OccurrenceRole::TypeOperand,
622            })
623            .collect()
624    }
625
626    fn extract(&self, node: Node<'_>, kind: NormalizedKind, sink: &mut RoleSink<'_>) {
627        if let Some(role) = python_occurrence_role(node) {
628            sink.occurrence_role(node, role);
629        }
630        match kind {
631            NormalizedKind::Call => {
632                if let Some(function) = node.child_by_field_name("function") {
633                    // A call's own name is its callee's, so
634                    // { "kind": "call", "name": "eval" } reads naturally.
635                    attach_terminal_callee(sink, function, expression_name_node(function));
636                    if function.kind() == "attribute"
637                        && let Some(object) = function.child_by_field_name("object")
638                    {
639                        attach_role_with_derived_name(
640                            sink,
641                            Role::Receiver,
642                            object,
643                            expression_name_node,
644                        );
645                    }
646                }
647                if let Some(arguments) = node.child_by_field_name("arguments") {
648                    for index in 0..arguments.named_child_count() {
649                        if !sink.should_continue() {
650                            break;
651                        }
652                        let Some(argument) = arguments.named_child(index) else {
653                            continue;
654                        };
655                        match argument.kind() {
656                            "comment" => {}
657                            "keyword_argument" => {
658                                if let (Some(keyword), Some(value)) = (
659                                    argument.child_by_field_name("name"),
660                                    argument.child_by_field_name("value"),
661                                ) {
662                                    sink.kwarg(keyword, value);
663                                }
664                            }
665                            _ => attach_argument_role_with_derived_name(
666                                sink,
667                                argument,
668                                expression_name_node,
669                            ),
670                        }
671                    }
672                }
673            }
674            NormalizedKind::FieldAccess => {
675                if let Some(attribute) = node.child_by_field_name("attribute") {
676                    sink.set_name(attribute);
677                    sink.role_named(Role::Field, attribute, attribute);
678                }
679                if let Some(object) = node.child_by_field_name("object") {
680                    attach_role_with_derived_name(sink, Role::Object, object, expression_name_node);
681                }
682            }
683            NormalizedKind::Function | NormalizedKind::Method | NormalizedKind::Class => {
684                if let Some(name) = node.child_by_field_name("name") {
685                    sink.set_name(name);
686                }
687                attach_decorators(sink, node);
688            }
689            NormalizedKind::Assignment => {
690                if let Some(left) = node.child_by_field_name("left") {
691                    attach_role_with_derived_name(sink, Role::Left, left, expression_name_node);
692                }
693                if let Some(right) = node.child_by_field_name("right") {
694                    attach_role_with_derived_name(sink, Role::Right, right, expression_name_node);
695                }
696            }
697            NormalizedKind::Import => match node.kind() {
698                "import_from_statement" => {
699                    if let Some(module) = node.child_by_field_name("module_name") {
700                        sink.role_named(Role::Module, module, module);
701                    }
702                }
703                _ => {
704                    for index in 0..node.named_child_count() {
705                        if !sink.should_continue() {
706                            break;
707                        }
708                        let Some(child) = node.named_child(index) else {
709                            continue;
710                        };
711                        match child.kind() {
712                            "dotted_name" => sink.role_named(Role::Module, child, child),
713                            "aliased_import" => {
714                                if let Some(name) = child.child_by_field_name("name") {
715                                    sink.role_named(Role::Module, name, name);
716                                }
717                            }
718                            _ => {}
719                        }
720                    }
721                }
722            },
723            NormalizedKind::Identifier => sink.set_name(node),
724            NormalizedKind::Decorator => {
725                if let Some(name) = first_named_child(node).and_then(expression_name_node) {
726                    sink.set_name(name);
727                }
728            }
729            NormalizedKind::ForLoop => {
730                if let Some(right) = node.child_by_field_name("right") {
731                    attach_role_with_derived_name(
732                        sink,
733                        Role::Iterable,
734                        right,
735                        expression_name_node,
736                    );
737                }
738            }
739            NormalizedKind::CollectionLiteral => {
740                for index in 0..node.named_child_count() {
741                    let Some(child) = node.named_child(index) else {
742                        continue;
743                    };
744                    if child.kind() == "comment" {
745                        continue;
746                    }
747                    attach_role_with_derived_name(sink, Role::Element, child, expression_name_node);
748                }
749            }
750            _ => {}
751        }
752    }
753}