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::structural::adapter_helpers::{
7    attach_argument_role_with_derived_name, attach_role_with_derived_name, attach_terminal_callee,
8    field_name_in_parent, first_named_child, nearest_ancestor, node_range,
9};
10use brokk_bifrost_core::analyzer::structural::callable::CallSiteContext;
11use brokk_bifrost_core::analyzer::structural::edges::{
12    DEEP_REFERENCE_EDGE_SUPPORT, ReferenceEdgeSupport,
13};
14use brokk_bifrost_core::analyzer::structural::kinds::{NormalizedKind, Role};
15use brokk_bifrost_core::analyzer::structural::materialization::{
16    DeclarationMaterializationSupport, PYTHON_MATERIALIZATION_SUPPORT,
17};
18use brokk_bifrost_core::analyzer::structural::occurrences::{
19    Namespace, OccurrenceRole, OccurrenceRoleSupport, default_occurrence_namespace,
20};
21use brokk_bifrost_core::analyzer::structural::resolution::{
22    BindingActivation, BindingKind, DEEP_LEXICAL_ENVIRONMENT_SUPPORT, HoistingClass,
23    LexicalEnvironmentSupport,
24};
25use brokk_bifrost_core::analyzer::structural::routes::{
26    DEEP_IDENTITY_AXES, IdentityRouteSupport, RouteHopKind,
27};
28use brokk_bifrost_core::analyzer::structural::spec::{EmbeddedLeafFact, RoleSink, StructuralSpec};
29use brokk_bifrost_core::analyzer::{Language, Range};
30use brokk_bifrost_core::cancellation::CancellationToken;
31use tree_sitter::Node;
32
33use crate::syntax::{
34    expression_name_node, python_deferred_annotation_identifier_ranges,
35    python_node_is_in_annotation,
36};
37
38#[derive(Debug, Default)]
39pub struct PythonStructuralSpec;
40
41pub static PYTHON_STRUCTURAL_SPEC: PythonStructuralSpec = PythonStructuralSpec;
42
43/// Grammar node-type → normalized kind. Every name here must exist in the
44/// tree-sitter-python grammar; `tests::python_kind_table_matches_grammar`
45/// asserts that, so a grammar bump that renames a node fails loudly.
46pub const PYTHON_KIND_TABLE: &[(&str, NormalizedKind)] = &[
47    ("call", NormalizedKind::Call),
48    ("attribute", NormalizedKind::FieldAccess),
49    ("function_definition", NormalizedKind::Function),
50    ("lambda", NormalizedKind::Lambda),
51    ("class_definition", NormalizedKind::Class),
52    ("assignment", NormalizedKind::Assignment),
53    ("import_statement", NormalizedKind::Import),
54    ("import_from_statement", NormalizedKind::Import),
55    ("identifier", NormalizedKind::Identifier),
56    ("string", NormalizedKind::StringLiteral),
57    ("concatenated_string", NormalizedKind::StringLiteral),
58    ("integer", NormalizedKind::NumericLiteral),
59    ("float", NormalizedKind::NumericLiteral),
60    ("true", NormalizedKind::BooleanLiteral),
61    ("false", NormalizedKind::BooleanLiteral),
62    ("none", NormalizedKind::NullLiteral),
63    ("return_statement", NormalizedKind::Return),
64    ("raise_statement", NormalizedKind::Throw),
65    ("except_clause", NormalizedKind::Catch),
66    ("if_statement", NormalizedKind::If),
67    ("for_statement", NormalizedKind::ForLoop),
68    ("while_statement", NormalizedKind::WhileLoop),
69    // Python's indented suite. The module node is deliberately absent: a file
70    // scope is not a statement list nested inside another one, and making the
71    // root a fact in one language only would give Python a scope shape no
72    // other adapter has.
73    ("block", NormalizedKind::Block),
74    ("decorator", NormalizedKind::Decorator),
75];
76
77/// Attach `decorators` edges for a definition wrapped in Python's
78/// `decorated_definition` node (which itself is not normalized).
79fn attach_decorators(sink: &mut RoleSink<'_>, definition: Node<'_>) {
80    let Some(parent) = definition.parent() else {
81        return;
82    };
83    if parent.kind() != "decorated_definition" {
84        return;
85    }
86    for index in 0..parent.named_child_count() {
87        let Some(child) = parent.named_child(index) else {
88            continue;
89        };
90        if child.kind() == "decorator" {
91            attach_role_with_derived_name(sink, Role::Decorator, child, expression_name_node);
92        }
93    }
94}
95
96static PYTHON_OCCURRENCE_ROLE_SUPPORT: OccurrenceRoleSupport = OccurrenceRoleSupport::NONE
97    .supported(OccurrenceRole::DeclarationName)
98    .supported(OccurrenceRole::Binder)
99    .supported(OccurrenceRole::LabelOrKey)
100    .supported(OccurrenceRole::TypeOperand)
101    .supported(OccurrenceRole::PathSegment)
102    .supported(OccurrenceRole::ImportAlias)
103    .supported(OccurrenceRole::ImportTarget)
104    .supported(OccurrenceRole::ReceiverPosition)
105    .supported(OccurrenceRole::MemberPosition)
106    .supported(OccurrenceRole::ValueReference);
107
108/// Whether a `dotted_name` names an imported module rather than an ordinary
109/// attribute chain, which decides whether its tail is an import target.
110fn python_dotted_name_is_import(dotted_name: Node<'_>) -> bool {
111    let mut current = dotted_name;
112    loop {
113        let Some(parent) = current.parent() else {
114            return false;
115        };
116        match parent.kind() {
117            "import_statement" | "import_from_statement" | "future_import_statement" => {
118                return true;
119            }
120            "aliased_import" | "dotted_name" => current = parent,
121            _ => return false,
122        }
123    }
124}
125
126/// Classify one Python identifier token by its AST position.
127///
128/// Python has no separate type-identifier node, so annotation operands are
129/// recognized by their enclosing `type` node — the same field the parser uses
130/// to separate `def f(x: T)`'s binder from its annotation.
131fn python_occurrence_role(node: Node<'_>) -> Option<OccurrenceRole> {
132    if node.kind() != "identifier" {
133        return None;
134    }
135    let parent = node.parent()?;
136    let field = field_name_in_parent(parent, node);
137    let role = match parent.kind() {
138        "function_definition" | "class_definition" if field == Some("name") => {
139            OccurrenceRole::DeclarationName
140        }
141        // Every annotation, return type and type-alias operand is wrapped in a
142        // `type` node; `generic_type`/`type_parameter` nest inside one.
143        "type" | "generic_type" | "type_parameter" | "constrained_type" | "union_type" => {
144            OccurrenceRole::TypeOperand
145        }
146        "parameters"
147        | "lambda_parameters"
148        | "typed_parameter"
149        | "list_splat_pattern"
150        | "dictionary_splat_pattern"
151        | "tuple_pattern"
152        | "list_pattern"
153        | "pattern_list"
154        | "as_pattern_target" => OccurrenceRole::Binder,
155        "default_parameter" | "typed_default_parameter" if field == Some("name") => {
156            OccurrenceRole::Binder
157        }
158        "for_statement" | "for_in_clause" if field == Some("left") => OccurrenceRole::Binder,
159        "keyword_argument" if field == Some("name") => OccurrenceRole::LabelOrKey,
160        "attribute" => match field {
161            Some("attribute") => OccurrenceRole::MemberPosition,
162            Some("object") => OccurrenceRole::ReceiverPosition,
163            _ => OccurrenceRole::ValueReference,
164        },
165        "aliased_import" if field == Some("alias") => OccurrenceRole::ImportAlias,
166        "import_from_statement" if field == Some("name") => OccurrenceRole::ImportTarget,
167        "dotted_name" => {
168            let is_tail =
169                parent.named_child(parent.named_child_count().saturating_sub(1)) == Some(node);
170            match (is_tail, python_dotted_name_is_import(parent)) {
171                (true, true) => OccurrenceRole::ImportTarget,
172                (true, false) => OccurrenceRole::ValueReference,
173                (false, _) => OccurrenceRole::PathSegment,
174            }
175        }
176        _ => OccurrenceRole::ValueReference,
177    };
178    Some(role)
179}
180
181/// Whether a `def` declares a method, that is, whether the suite it sits in
182/// belongs to a `class_definition`.
183///
184/// This reads the parse tree rather than the nearest enclosing normalized kind
185/// because the suite between a class and its methods is itself a normalized
186/// node now (`NormalizedKind::Block`, issue #1474). Walking the concrete
187/// ancestors is also the more direct statement of the rule: a nested `def`
188/// inside a method reaches its enclosing `function_definition` first and stays
189/// a function.
190fn python_definition_is_method(definition: Node<'_>) -> bool {
191    let mut current = definition;
192    while let Some(parent) = current.parent() {
193        match parent.kind() {
194            "class_definition" => return true,
195            // The suite that holds the members, and the wrapper a decorated
196            // definition sits in, are pass-through on the way to the owner.
197            "block" | "decorated_definition" => current = parent,
198            _ => return false,
199        }
200    }
201    false
202}
203
204/// The binding one Python binder token introduces, and the interval it is in
205/// effect over.
206///
207/// Python's function locals are scope-categorical rather than positional: a
208/// name assigned anywhere in a function body is a local of that function for
209/// the whole body, which is why a read above the assignment is an
210/// `UnboundLocalError` rather than a read of an outer name. That is exactly
211/// `ScopeWide`. The one positional exception is a comprehension target, which
212/// lives in the comprehension's own implicit scope; the same exception
213/// `analyzer::python::bindings` records as `PythonComprehensionBinding`.
214fn python_binding_activation(binder: Node<'_>, scope: Range) -> Option<BindingActivation> {
215    let form = nearest_ancestor(binder, |kind| {
216        matches!(
217            kind,
218            "parameters"
219                | "lambda_parameters"
220                | "for_statement"
221                | "for_in_clause"
222                | "as_pattern"
223                | "list_comprehension"
224                | "set_comprehension"
225                | "dictionary_comprehension"
226                | "generator_expression"
227                | "function_definition"
228        )
229    })?;
230    match form.kind() {
231        "parameters" | "lambda_parameters" | "function_definition" => Some(BindingActivation {
232            kind: BindingKind::Parameter,
233            hoisting: HoistingClass::ScopeWide,
234            activation: scope,
235        }),
236        "for_in_clause" => {
237            // A comprehension clause binds only inside the comprehension.
238            let comprehension = nearest_ancestor(form, |kind| {
239                matches!(
240                    kind,
241                    "list_comprehension"
242                        | "set_comprehension"
243                        | "dictionary_comprehension"
244                        | "generator_expression"
245                )
246            })?;
247            Some(BindingActivation {
248                kind: BindingKind::LoopVariable,
249                hoisting: HoistingClass::DeclaredHead,
250                activation: node_range(comprehension),
251            })
252        }
253        "for_statement" => Some(BindingActivation {
254            kind: BindingKind::LoopVariable,
255            hoisting: HoistingClass::ScopeWide,
256            activation: scope,
257        }),
258        "as_pattern" => Some(BindingActivation {
259            kind: BindingKind::PatternBinder,
260            hoisting: HoistingClass::ScopeWide,
261            activation: scope,
262        }),
263        _ => Some(BindingActivation {
264            kind: BindingKind::Local,
265            hoisting: HoistingClass::ScopeWide,
266            activation: scope,
267        }),
268    }
269}
270
271impl StructuralSpec for PythonStructuralSpec {
272    fn language(&self) -> Language {
273        Language::Python
274    }
275
276    fn reference_edge_support(&self) -> &ReferenceEdgeSupport {
277        &DEEP_REFERENCE_EDGE_SUPPORT
278    }
279
280    fn identity_route_support(&self) -> &IdentityRouteSupport {
281        // `import x as y` is an alias. Python's re-export shapes (an
282        // `__init__` facade, `__all__`) are conventions over files rather
283        // than statements a parse tree names, so the relation stays
284        // unclaimed until a producer models them (see the #1475 ExecPlan
285        // Decision Log, M3).
286        static SUPPORT: IdentityRouteSupport = DEEP_IDENTITY_AXES
287            .supported_relation(RouteHopKind::Alias)
288            .supported_relation(RouteHopKind::Import)
289            .supported_relation(RouteHopKind::NestedOwner);
290        &SUPPORT
291    }
292
293    /// Python's one qualified-path chain is `dotted_name`, which is flat
294    /// rather than left-nested: its named children are the segments in order.
295    fn qualified_path_root<'tree>(&self, token: Node<'tree>) -> Option<Node<'tree>> {
296        if token.kind() != "identifier" {
297            return None;
298        }
299        token
300            .parent()
301            .filter(|parent| parent.kind() == "dotted_name")
302    }
303
304    fn path_segment_tokens<'tree>(&self, root: Node<'tree>) -> Vec<Node<'tree>> {
305        if root.kind() != "dotted_name" {
306            return Vec::new();
307        }
308        let mut cursor = root.walk();
309        root.named_children(&mut cursor)
310            .filter(|child| child.kind() == "identifier")
311            .collect()
312    }
313
314    fn indirection_relation(&self, token: Node<'_>) -> Option<RouteHopKind> {
315        nearest_ancestor(token, |kind| {
316            matches!(kind, "import_statement" | "import_from_statement")
317        })
318        .map(|_| RouteHopKind::Import)
319    }
320
321    fn kind_table(&self) -> &'static [(&'static str, NormalizedKind)] {
322        PYTHON_KIND_TABLE
323    }
324
325    fn refine_kind(
326        &self,
327        node: Node<'_>,
328        kind: NormalizedKind,
329        _enclosing: Option<NormalizedKind>,
330        _source: &str,
331        _context: &CallSiteContext,
332    ) -> NormalizedKind {
333        if kind == NormalizedKind::Function && python_definition_is_method(node) {
334            NormalizedKind::Method
335        } else {
336            kind
337        }
338    }
339
340    fn should_extract(&self, node: Node<'_>, kind: NormalizedKind) -> bool {
341        kind != NormalizedKind::Assignment || node.child_by_field_name("right").is_some()
342    }
343
344    fn supports_kind(&self, kind: NormalizedKind) -> bool {
345        kind == NormalizedKind::Method
346            || self
347                .kind_table()
348                .iter()
349                .any(|(_, fact_kind)| fact_kind.satisfies(kind))
350    }
351
352    fn occurrence_role_support(&self) -> &OccurrenceRoleSupport {
353        &PYTHON_OCCURRENCE_ROLE_SUPPORT
354    }
355
356    fn lexical_environment_support(&self) -> &LexicalEnvironmentSupport {
357        &DEEP_LEXICAL_ENVIRONMENT_SUPPORT
358    }
359
360    fn materialization_support(&self) -> &DeclarationMaterializationSupport {
361        &PYTHON_MATERIALIZATION_SUPPORT
362    }
363
364    fn binding_activation(&self, binder: Node<'_>, scope: Range) -> Option<BindingActivation> {
365        python_binding_activation(binder, scope)
366    }
367
368    /// Python only classifies a scope segment inside a `dotted_name`, and every
369    /// non-tail segment of a dotted name is a module.
370    fn occurrence_namespace(
371        &self,
372        role: OccurrenceRole,
373        declares: Option<NormalizedKind>,
374    ) -> Option<Namespace> {
375        match role {
376            OccurrenceRole::PathSegment => Some(Namespace::Module),
377            _ => default_occurrence_namespace(role, declares),
378        }
379    }
380
381    fn embedded_leaf_facts(
382        &self,
383        node: Node<'_>,
384        kind: NormalizedKind,
385        source: &str,
386        cancellation: Option<&CancellationToken>,
387    ) -> Vec<EmbeddedLeafFact> {
388        if kind != NormalizedKind::StringLiteral
389            || node.kind() != "string"
390            || !python_node_is_in_annotation(node)
391        {
392            return Vec::new();
393        }
394
395        python_deferred_annotation_identifier_ranges(node, source, cancellation)
396            .unwrap_or_default()
397            .into_iter()
398            .map(|range| EmbeddedLeafFact {
399                kind: NormalizedKind::Identifier,
400                range,
401                occurrence_role: OccurrenceRole::TypeOperand,
402            })
403            .collect()
404    }
405
406    fn extract(&self, node: Node<'_>, kind: NormalizedKind, sink: &mut RoleSink<'_>) {
407        if let Some(role) = python_occurrence_role(node) {
408            sink.occurrence_role(node, role);
409        }
410        match kind {
411            NormalizedKind::Call => {
412                if let Some(function) = node.child_by_field_name("function") {
413                    // A call's own name is its callee's, so
414                    // { "kind": "call", "name": "eval" } reads naturally.
415                    attach_terminal_callee(sink, function, expression_name_node(function));
416                    if function.kind() == "attribute"
417                        && let Some(object) = function.child_by_field_name("object")
418                    {
419                        attach_role_with_derived_name(
420                            sink,
421                            Role::Receiver,
422                            object,
423                            expression_name_node,
424                        );
425                    }
426                }
427                if let Some(arguments) = node.child_by_field_name("arguments") {
428                    for index in 0..arguments.named_child_count() {
429                        if !sink.should_continue() {
430                            break;
431                        }
432                        let Some(argument) = arguments.named_child(index) else {
433                            continue;
434                        };
435                        match argument.kind() {
436                            "comment" => {}
437                            "keyword_argument" => {
438                                if let (Some(keyword), Some(value)) = (
439                                    argument.child_by_field_name("name"),
440                                    argument.child_by_field_name("value"),
441                                ) {
442                                    sink.kwarg(keyword, value);
443                                }
444                            }
445                            _ => attach_argument_role_with_derived_name(
446                                sink,
447                                argument,
448                                expression_name_node,
449                            ),
450                        }
451                    }
452                }
453            }
454            NormalizedKind::FieldAccess => {
455                if let Some(attribute) = node.child_by_field_name("attribute") {
456                    sink.set_name(attribute);
457                    sink.role_named(Role::Field, attribute, attribute);
458                }
459                if let Some(object) = node.child_by_field_name("object") {
460                    attach_role_with_derived_name(sink, Role::Object, object, expression_name_node);
461                }
462            }
463            NormalizedKind::Function | NormalizedKind::Method | NormalizedKind::Class => {
464                if let Some(name) = node.child_by_field_name("name") {
465                    sink.set_name(name);
466                }
467                attach_decorators(sink, node);
468            }
469            NormalizedKind::Assignment => {
470                if let Some(left) = node.child_by_field_name("left") {
471                    attach_role_with_derived_name(sink, Role::Left, left, expression_name_node);
472                }
473                if let Some(right) = node.child_by_field_name("right") {
474                    attach_role_with_derived_name(sink, Role::Right, right, expression_name_node);
475                }
476            }
477            NormalizedKind::Import => match node.kind() {
478                "import_from_statement" => {
479                    if let Some(module) = node.child_by_field_name("module_name") {
480                        sink.role_named(Role::Module, module, module);
481                    }
482                }
483                _ => {
484                    for index in 0..node.named_child_count() {
485                        if !sink.should_continue() {
486                            break;
487                        }
488                        let Some(child) = node.named_child(index) else {
489                            continue;
490                        };
491                        match child.kind() {
492                            "dotted_name" => sink.role_named(Role::Module, child, child),
493                            "aliased_import" => {
494                                if let Some(name) = child.child_by_field_name("name") {
495                                    sink.role_named(Role::Module, name, name);
496                                }
497                            }
498                            _ => {}
499                        }
500                    }
501                }
502            },
503            NormalizedKind::Identifier => sink.set_name(node),
504            NormalizedKind::Decorator => {
505                if let Some(name) = first_named_child(node).and_then(expression_name_node) {
506                    sink.set_name(name);
507                }
508            }
509            _ => {}
510        }
511    }
512}