Skip to main content

brokk_bifrost_python/
syntax.rs

1use brokk_bifrost_core::analyzer::Range;
2use brokk_bifrost_core::analyzer::common::node_source_text as node_text;
3use brokk_bifrost_core::cancellation::CancellationToken;
4use brokk_bifrost_core::hash::HashSet;
5use tree_sitter::{Node, Tree};
6
7pub(crate) fn python_parameter_name(node: Node<'_>, source: &str) -> Option<String> {
8    python_parameter_name_bounded(node, source, || true)
9        .expect("unbounded parameter name lookup completes")
10}
11
12pub(crate) fn python_parameter_name_bounded(
13    node: Node<'_>,
14    source: &str,
15    scope_step: impl FnMut() -> bool,
16) -> Option<Option<String>> {
17    python_parameter_node_bounded(node, scope_step).map(|node| {
18        node.and_then(|node| {
19            let name = node_text(node, source).trim();
20            (!name.is_empty()).then(|| name.to_owned())
21        })
22    })
23}
24
25/// The exact binder token, shared by signature and native lexical production.
26pub(crate) fn python_parameter_node(node: Node<'_>) -> Option<Node<'_>> {
27    python_parameter_node_bounded(node, || true).expect("unbounded parameter token lookup")
28}
29
30fn python_parameter_node_bounded(
31    mut node: Node<'_>,
32    mut scope_step: impl FnMut() -> bool,
33) -> Option<Option<Node<'_>>> {
34    loop {
35        if !scope_step() {
36            return None;
37        }
38        match node.kind() {
39            "identifier" => return Some((!node.is_missing()).then_some(node)),
40            "typed_parameter"
41            | "typed_default_parameter"
42            | "default_parameter"
43            | "list_splat_pattern"
44            | "dictionary_splat_pattern" => {
45                if let Some(name) = node.child_by_field_name("name") {
46                    node = name;
47                    continue;
48                }
49                let mut selected = None;
50                let mut cursor = node.walk();
51                for child in node.named_children(&mut cursor) {
52                    if !scope_step() {
53                        return None;
54                    }
55                    if matches!(
56                        child.kind(),
57                        "identifier" | "list_splat_pattern" | "dictionary_splat_pattern"
58                    ) {
59                        selected = Some(child);
60                        break;
61                    }
62                }
63                let Some(name) = selected else {
64                    return Some(None);
65                };
66                node = name;
67            }
68            _ => return Some(None),
69        }
70    }
71}
72
73pub fn leftmost_identifier(mut node: Node<'_>) -> Option<Node<'_>> {
74    loop {
75        match node.kind() {
76            "identifier" => return Some(node),
77            "attribute" => node = node.child_by_field_name("object")?,
78            _ => return None,
79        }
80    }
81}
82
83pub(crate) fn attribute_chain<'tree>(node: Node<'tree>) -> Option<(Node<'tree>, Vec<Node<'tree>>)> {
84    let mut attributes = Vec::new();
85    let mut current = node;
86    loop {
87        if current.kind() != "attribute" {
88            return None;
89        }
90        attributes.push(current.child_by_field_name("attribute")?);
91        current = current.child_by_field_name("object")?;
92        if current.kind() == "identifier" {
93            attributes.reverse();
94            return Some((current, attributes));
95        }
96    }
97}
98
99pub fn python_reference_is_deferred_function_body(node: Node<'_>) -> bool {
100    python_reference_is_deferred_function_body_bounded(node, || true)
101        .expect("unbounded deferred-context lookup completes")
102}
103
104/// Body containment under the caller's sticky traversal budget.
105pub fn python_reference_is_deferred_function_body_bounded(
106    node: Node<'_>,
107    mut scope_step: impl FnMut() -> bool,
108) -> Option<bool> {
109    let site_start = node.start_byte();
110    let site_end = node.end_byte();
111    let mut current = node;
112    while let Some(parent) = current.parent() {
113        if !scope_step() {
114            return None;
115        }
116        if matches!(parent.kind(), "function_definition" | "lambda")
117            && parent
118                .child_by_field_name("body")
119                .is_some_and(|body| body.start_byte() <= site_start && site_end <= body.end_byte())
120        {
121            return Some(true);
122        }
123        current = parent;
124    }
125    Some(false)
126}
127
128/// The identifier nodes in one static Python value path, from root to leaf.
129///
130/// Dynamic receivers and subscripts have no static path. Keeping the nodes
131/// lets callers combine the parser shape with lexical/import binding facts
132/// without reparsing a dotted source spelling.
133pub fn python_static_attribute_path<'tree>(node: Node<'tree>) -> Option<Vec<Node<'tree>>> {
134    python_static_value_path_with(node, || true, |_| None)
135        .expect("unbounded static path lookup completes")
136}
137
138/// The strict static attribute path proof, charging traversal and reversal.
139/// Parentheses remain outside this API's admitted grammar.
140pub fn python_static_attribute_path_bounded<'tree>(
141    node: Node<'tree>,
142    scope_step: impl FnMut() -> bool,
143) -> Option<Vec<Node<'tree>>> {
144    python_static_value_path_with(node, scope_step, |_| None).flatten()
145}
146
147/// A metered static value path, including transparent parentheses. The outer
148/// `None` means exhaustion; `Some(None)` means the receiver is not static.
149pub fn python_static_value_path_bounded<'tree>(
150    node: Node<'tree>,
151    scope_step: impl FnMut() -> bool,
152) -> Option<Option<Vec<Node<'tree>>>> {
153    python_static_value_path_with(
154        node,
155        scope_step,
156        crate::structural::python_parenthesized_expression_child,
157    )
158}
159
160fn python_static_value_path_with<'tree>(
161    mut node: Node<'tree>,
162    mut scope_step: impl FnMut() -> bool,
163    mut parenthesized_child: impl FnMut(Node<'tree>) -> Option<Node<'tree>>,
164) -> Option<Option<Vec<Node<'tree>>>> {
165    let mut path = Vec::new();
166    loop {
167        if !scope_step() {
168            return None;
169        }
170        match node.kind() {
171            "identifier" => {
172                path.push(node);
173                break;
174            }
175            "attribute" => {
176                let Some(object) = push_static_attribute_segment(node, &mut path) else {
177                    return Some(None);
178                };
179                node = object;
180            }
181            "parenthesized_expression" => {
182                // The shared grammar helper skips comments. Charge all of its
183                // immediate inputs before it inspects them.
184                for _ in brokk_bifrost_core::analyzer::tree_walk::named_children_iter(node) {
185                    if !scope_step() {
186                        return None;
187                    }
188                }
189                let Some(child) = parenthesized_child(node) else {
190                    return Some(None);
191                };
192                node = child;
193            }
194            _ => return Some(None),
195        }
196    }
197    for _ in 0..path.len() {
198        if !scope_step() {
199            return None;
200        }
201    }
202    path.reverse();
203    Some(Some(path))
204}
205
206/// The identifier nodes in one static Python annotation name, root to leaf.
207/// Wrappers and generic arguments are ignored while the named generic origin
208/// remains part of the path.
209pub fn python_static_type_path<'tree>(mut node: Node<'tree>) -> Option<Vec<Node<'tree>>> {
210    let mut path = Vec::new();
211    loop {
212        match node.kind() {
213            "identifier" => {
214                path.push(node);
215                break;
216            }
217            "type" | "generic_type" | "subscript" => node = node.named_child(0)?,
218            "attribute" => {
219                node = push_static_attribute_segment(node, &mut path)?;
220            }
221            "member_type" => {
222                let mut cursor = node.walk();
223                let mut children = node.named_children(&mut cursor);
224                let qualifier = children.next()?;
225                let member = children.next()?;
226                if member.kind() != "identifier" || children.next().is_some() {
227                    return None;
228                }
229                path.push(member);
230                node = qualifier;
231            }
232            _ => return None,
233        }
234    }
235    path.reverse();
236    Some(path)
237}
238
239fn push_static_attribute_segment<'tree>(
240    node: Node<'tree>,
241    path: &mut Vec<Node<'tree>>,
242) -> Option<Node<'tree>> {
243    let attribute = node.child_by_field_name("attribute")?;
244    if attribute.kind() != "identifier" {
245        return None;
246    }
247    path.push(attribute);
248    node.child_by_field_name("object")
249}
250
251/// The text one plain string literal denotes.
252///
253/// Prefixed strings, interpolations, escapes, implicit concatenations, and
254/// malformed shapes return `None`. The returned slice is always the exact
255/// `string_content` AST span, never text recovered by delimiter parsing.
256pub fn python_plain_string_literal<'source>(
257    node: Node<'_>,
258    source: &'source str,
259) -> Option<&'source str> {
260    if node.kind() != "string"
261        || node
262            .parent()
263            .is_some_and(|parent| parent.kind() == "concatenated_string")
264    {
265        return None;
266    }
267    let mut content = None;
268    let mut cursor = node.walk();
269    for child in node.named_children(&mut cursor) {
270        match child.kind() {
271            "string_start" | "string_end" => {
272                let delimiter = child.utf8_text(source.as_bytes()).ok()?;
273                if delimiter
274                    .chars()
275                    .any(|character| character != '"' && character != '\'')
276                {
277                    return None;
278                }
279            }
280            "string_content" if child.named_child_count() == 0 && content.is_none() => {
281                content = Some(child);
282            }
283            _ => return None,
284        }
285    }
286    Some(content.map_or("", |child| {
287        child
288            .utf8_text(source.as_bytes())
289            .expect("a tree-sitter node range is valid UTF-8 source")
290    }))
291}
292
293#[derive(Debug, Default)]
294pub struct PythonOverloadDecoratorBindings {
295    direct: HashSet<String>,
296    namespaces: HashSet<String>,
297}
298
299#[derive(Debug)]
300pub(crate) enum PythonOverloadDecoratorName {
301    Direct(String),
302    Namespace(String),
303}
304
305impl PythonOverloadDecoratorBindings {
306    /// Capture typing bindings from the import interpretation already owned by
307    /// the primary producer. Only module-level leaves reach this collector.
308    pub(crate) fn collect_import(
309        &mut self,
310        import: &brokk_bifrost_core::analyzer::model::ImportInfo,
311    ) {
312        use brokk_bifrost_core::analyzer::model::StructuredImportPathKind;
313        let Some(path) = &import.path else { return };
314        match (path.kind, path.segments.as_slice()) {
315            (Some(StructuredImportPathKind::Namespace), [module]) if is_typing_module(module) => {
316                self.namespaces
317                    .insert(import.alias.as_ref().unwrap_or(module).clone());
318            }
319            (Some(StructuredImportPathKind::ImportFrom), [module, name])
320                if is_typing_module(module) && name == "overload" =>
321            {
322                self.direct
323                    .insert(import.alias.as_ref().unwrap_or(name).clone());
324            }
325            _ => {}
326        }
327    }
328
329    /// Return the exact decorator binding shapes used by one function. The
330    /// names are captured during the primary walk; the import binding set is
331    /// resolved after the walk so a later typing import is handled exactly as
332    /// the historical whole-file predicate did.
333    pub(crate) fn overload_decorator_names(
334        function: Node<'_>,
335        source: &str,
336    ) -> Vec<PythonOverloadDecoratorName> {
337        let Some(parent) = function
338            .parent()
339            .filter(|node| node.kind() == "decorated_definition")
340        else {
341            return Vec::new();
342        };
343        let mut cursor = parent.walk();
344        parent
345            .named_children(&mut cursor)
346            .filter(|child| child.kind() == "decorator")
347            .filter_map(decorator_callee)
348            .filter_map(|callee| match callee.kind() {
349                "identifier" => Some(PythonOverloadDecoratorName::Direct(
350                    node_text(callee, source).trim().to_string(),
351                )),
352                "attribute" => {
353                    let attribute = callee.child_by_field_name("attribute")?;
354                    if node_text(attribute, source).trim() != "overload" {
355                        return None;
356                    }
357                    let object = callee.child_by_field_name("object")?;
358                    (object.kind() == "identifier").then(|| {
359                        PythonOverloadDecoratorName::Namespace(
360                            node_text(object, source).trim().to_string(),
361                        )
362                    })
363                }
364                _ => None,
365            })
366            .collect()
367    }
368
369    pub(crate) fn matches_overload_binding(&self, name: &PythonOverloadDecoratorName) -> bool {
370        match name {
371            PythonOverloadDecoratorName::Direct(name) => self.direct.contains(name),
372            PythonOverloadDecoratorName::Namespace(name) => self.namespaces.contains(name),
373        }
374    }
375}
376
377fn is_typing_module(module: &str) -> bool {
378    matches!(module, "typing" | "typing_extensions")
379}
380
381/// Return the name-bearing node of a Python expression using tree-sitter fields.
382pub fn expression_name_node<'tree>(expression: Node<'tree>) -> Option<Node<'tree>> {
383    let mut current = expression;
384    loop {
385        match current.kind() {
386            "identifier" => return Some(current),
387            "attribute" => current = current.child_by_field_name("attribute")?,
388            "call" => current = current.child_by_field_name("function")?,
389            _ => return None,
390        }
391    }
392}
393
394/// Whether `node` is the label of a keyword argument: the `x` in `f(x=1)`.
395///
396/// The label names a parameter or member at the CALLEE, selected by the call's
397/// target, not by anything bound where the label is written. That is why the
398/// occurrence-role adapter in `structural.rs` classifies it `LabelOrKey`, whose
399/// occurrence class is `NonReference`, and it is the rule the reference census
400/// reads to decide that the label is not a forward-reference probe (#2054).
401pub fn python_keyword_argument_label(node: Node<'_>) -> bool {
402    node.kind() == "identifier"
403        && node.parent().is_some_and(|parent| {
404            parent.kind() == "keyword_argument" && parent.child_by_field_name("name") == Some(node)
405        })
406}
407
408/// Return a decorator's callable expression, peeling an optional invocation.
409pub fn decorator_callee<'tree>(decorator: Node<'tree>) -> Option<Node<'tree>> {
410    if decorator.kind() != "decorator" {
411        return None;
412    }
413    // The grammar can interleave a `line_continuation` extra before the
414    // decorated expression; skip extras so `@\`-continued names resolve.
415    let mut cursor = decorator.walk();
416    let mut expression = decorator
417        .named_children(&mut cursor)
418        .find(|child| !child.is_extra())?;
419    while expression.kind() == "call" {
420        expression = expression.child_by_field_name("function")?;
421    }
422    Some(expression)
423}
424
425/// Whether `node` is contained by a parser field that Python evaluates as an
426/// annotation rather than as an ordinary expression.
427pub fn python_node_is_in_annotation(node: Node<'_>) -> bool {
428    let start = node.start_byte();
429    let end = node.end_byte();
430    let mut current = node;
431    while let Some(parent) = current.parent() {
432        let annotation = match parent.kind() {
433            "function_definition" => parent.child_by_field_name("return_type"),
434            "typed_parameter" | "typed_default_parameter" | "assignment" => {
435                parent.child_by_field_name("type")
436            }
437            _ => None,
438        };
439        if let Some(annotation) = annotation
440            && annotation.start_byte() <= start
441            && end <= annotation.end_byte()
442        {
443            return true;
444        }
445        current = parent;
446    }
447    false
448}
449
450/// Parse one exactly mapped deferred annotation and return its identifier ranges.
451pub fn python_deferred_annotation_identifier_ranges(
452    string: Node<'_>,
453    source: &str,
454    cancellation: Option<&CancellationToken>,
455) -> Option<Vec<Range>> {
456    let tree = python_deferred_annotation_tree(string, source, cancellation)?;
457
458    let mut ranges = Vec::new();
459    let mut stack = vec![tree.root_node()];
460    while let Some(current) = stack.pop() {
461        if cancellation.is_some_and(CancellationToken::is_cancelled) {
462            return None;
463        }
464        if current.kind() == "identifier" {
465            ranges.push(Range {
466                start_byte: current.start_byte(),
467                end_byte: current.end_byte(),
468                start_line: current.start_position().row + 1,
469                end_line: current.end_position().row + 1,
470            });
471        }
472        for index in (0..current.named_child_count()).rev() {
473            if let Some(child) = current.named_child(index) {
474                stack.push(child);
475            }
476        }
477    }
478    Some(ranges)
479}
480
481/// Parse one quoted annotation expression while preserving its original source
482/// byte coordinates. Literal string values and arbitrary strings are rejected
483/// by the same structured gate used by inverse membership.
484pub fn python_deferred_annotation_tree(
485    string: Node<'_>,
486    source: &str,
487    cancellation: Option<&CancellationToken>,
488) -> Option<Tree> {
489    if string.kind() != "string"
490        || string
491            .parent()
492            .is_some_and(|parent| parent.kind() == "concatenated_string")
493        || !python_node_is_in_annotation(string)
494        || python_string_is_literal_value(string, source)
495    {
496        return None;
497    }
498
499    let mut content = None;
500    for index in 0..string.named_child_count() {
501        let child = string.named_child(index)?;
502        match child.kind() {
503            "string_start" | "string_end" => {}
504            "string_content" if content.is_none() => content = Some(child),
505            _ => return None,
506        }
507    }
508    let content = content?;
509    let language = tree_sitter_python::LANGUAGE.into();
510    let tree = brokk_bifrost_core::analyzer::common::parse_source_range_with_cancellation(
511        &language,
512        source,
513        content.range(),
514        cancellation,
515    )?;
516    if tree.root_node().has_error() {
517        return None;
518    }
519    Some(tree)
520}
521
522/// Whether `string` is a value argument of `Literal[...]`, rather than a
523/// deferred type expression merely because the whole subscript is an
524/// annotation.
525fn python_string_is_literal_value(string: Node<'_>, source: &str) -> bool {
526    let start = string.start_byte();
527    let end = string.end_byte();
528    let mut current = string;
529    while let Some(parent) = current.parent() {
530        match parent.kind() {
531            "subscript" => {
532                let Some(value) = parent.child_by_field_name("value") else {
533                    return false;
534                };
535                if value.start_byte() <= start && end <= value.end_byte() {
536                    return false;
537                }
538                return python_literal_annotation_base(value, source);
539            }
540            "generic_type" => {
541                let Some(value) = parent.named_child(0) else {
542                    return false;
543                };
544                return python_literal_annotation_base(value, source);
545            }
546            _ => current = parent,
547        }
548    }
549    false
550}
551
552fn python_literal_annotation_base(value: Node<'_>, source: &str) -> bool {
553    match value.kind() {
554        "identifier" => node_text(value, source) == "Literal",
555        "attribute" => {
556            let (Some(object), Some(attribute)) = (
557                value.child_by_field_name("object"),
558                value.child_by_field_name("attribute"),
559            ) else {
560                return false;
561            };
562            object.kind() == "identifier"
563                && matches!(node_text(object, source), "typing" | "typing_extensions")
564                && attribute.kind() == "identifier"
565                && node_text(attribute, source) == "Literal"
566        }
567        "member_type" => {
568            let mut identifiers = Vec::new();
569            let mut stack = vec![value];
570            while let Some(node) = stack.pop() {
571                if node.kind() == "identifier" {
572                    identifiers.push(node_text(node, source));
573                    continue;
574                }
575                for index in (0..node.named_child_count()).rev() {
576                    if let Some(child) = node.named_child(index) {
577                        stack.push(child);
578                    }
579                }
580            }
581            matches!(
582                identifiers.as_slice(),
583                ["typing", "Literal"] | ["typing_extensions", "Literal"]
584            )
585        }
586        _ => false,
587    }
588}
589
590/// A structurally selected subtree mentioning the module export identifier.
591/// Used by both legacy recovery and native export-operation classification.
592pub(crate) fn python_contains_all_identifier(node: Node<'_>, source: &str) -> bool {
593    use brokk_bifrost_core::analyzer::tree_walk::{WalkControl, walk_named_tree_preorder};
594    let mut found = false;
595    walk_named_tree_preorder(node, true, |node| {
596        if node.kind() == "identifier" && &source[node.byte_range()] == "__all__" {
597            found = true;
598            WalkControl::Break
599        } else {
600            WalkControl::Continue
601        }
602    });
603    found
604}
605
606#[cfg(test)]
607mod tests {
608    use brokk_bifrost_test_macros::hardwon;
609
610    #[test]
611    #[hardwon("#5044: commented parentheses must preserve import identity and budget exhaustion")]
612    fn static_paths_keep_parentheses_comments_and_exhaustion_distinct() {
613        let source = "(  # receiver\n    module).function\n";
614        let tree = crate::declarations::parse_python_tree(source).unwrap();
615        let expression = tree
616            .root_node()
617            .named_child(0)
618            .unwrap()
619            .named_child(0)
620            .unwrap();
621        // Preserve the producer-facing helper's existing admission contract.
622        assert!(super::python_static_attribute_path(expression).is_none());
623        assert!(super::python_static_value_path_bounded(expression, || false).is_none());
624        let path = super::python_static_value_path_bounded(expression, || true)
625            .unwrap()
626            .unwrap();
627        assert_eq!(
628            path.iter()
629                .map(|node| node.utf8_text(source.as_bytes()).unwrap())
630                .collect::<Vec<_>>(),
631            ["module", "function"]
632        );
633        let dynamic = crate::declarations::parse_python_tree("factory().function\n").unwrap();
634        let expression = dynamic
635            .root_node()
636            .named_child(0)
637            .unwrap()
638            .named_child(0)
639            .unwrap();
640        assert!(matches!(
641            super::python_static_value_path_bounded(expression, || true),
642            Some(None)
643        ));
644    }
645}