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