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