Skip to main content

brokk_bifrost_python/
source_facts.rs

1//! Mounted read-side input for Python declaration consumers.
2
3use brokk_bifrost_core::analyzer::ProjectFile;
4use brokk_bifrost_core::analyzer::Range;
5use brokk_bifrost_core::analyzer::python_facts::PythonSourceFacts;
6use brokk_bifrost_core::analyzer::query_token::QueryToken;
7use brokk_bifrost_core::analyzer::source_facts::SourceFactRows;
8use std::collections::{HashMap, HashSet};
9use std::mem::size_of;
10use std::sync::Arc;
11use tree_sitter::Node;
12
13/// Syntax-only proof candidates for module-level initializer values.
14///
15/// This is deliberately separate from [`PythonFileSourceFacts`]: that type is
16/// a read projection over persisted rows, while this value is derived from the
17/// current parse tree and belongs in the analyzer's generation/blob/file
18/// cache. A returned range identifies only the RHS of a single, plain module
19/// assignment; callers still classify that expression before using it.
20#[derive(Debug, Clone, Default)]
21pub struct PythonModuleGlobalSourceFacts {
22    bindings: HashMap<Box<str>, PythonModuleGlobalBindingFact>,
23    global_declarations: HashSet<Box<str>>,
24    wildcard_import_offsets: Vec<usize>,
25    has_dynamic_write: bool,
26    has_exec: bool,
27}
28
29#[derive(Debug, Clone, Default)]
30struct PythonModuleGlobalBindingFact {
31    statements: u32,
32    initializer: Option<Range>,
33    initializer_statement_end: usize,
34}
35
36impl PythonModuleGlobalSourceFacts {
37    /// Whether any module-scope syntax binds this name.
38    pub fn module_binds_name(&self, name: &str) -> bool {
39        self.bindings.contains_key(name)
40    }
41
42    /// The initializer's RHS range only when every local syntactic proof holds.
43    pub fn initializer_range(&self, name: &str) -> Option<Range> {
44        if self.has_dynamic_write || self.has_exec || self.global_declarations.contains(name) {
45            return None;
46        }
47        let binding = self.bindings.get(name)?;
48        if binding.statements != 1 {
49            return None;
50        }
51        if self
52            .wildcard_import_offsets
53            .iter()
54            .any(|offset| *offset > binding.initializer_statement_end)
55        {
56            return None;
57        }
58        binding.initializer
59    }
60
61    /// Approximate retained bytes for the analyzer's weighted per-file cache.
62    pub fn estimated_retained_bytes(&self) -> usize {
63        size_of::<Self>()
64            + self
65                .bindings
66                .keys()
67                .map(|name| name.len() + size_of::<PythonModuleGlobalBindingFact>())
68                .sum::<usize>()
69            + self
70                .global_declarations
71                .iter()
72                .map(|name| name.len() + size_of::<Box<str>>())
73                .sum::<usize>()
74            + self.wildcard_import_offsets.len() * size_of::<usize>()
75    }
76}
77
78/// Collect bounded, per-file module-binding syntax facts.
79///
80/// The module binding walk skips function, lambda and class bodies, but still
81/// visits their headers because decorators and defaults execute in the
82/// enclosing scope. A second iterative walk records `global` declarations and
83/// reflective writes wherever they occur in the module.
84pub fn python_module_global_source_facts(
85    root: Node<'_>,
86    source: &str,
87    max_nodes: usize,
88    mut keep_going: impl FnMut() -> bool,
89) -> Option<PythonModuleGlobalSourceFacts> {
90    if root.kind() != "module" || root.has_error() {
91        return None;
92    }
93    let mut remaining = max_nodes;
94    let mut facts = PythonModuleGlobalSourceFacts::default();
95    let mut stack = vec![(root, true)];
96    while let Some((node, initializer_path)) = stack.pop() {
97        if remaining == 0 || !keep_going() {
98            return None;
99        }
100        remaining -= 1;
101
102        if node.kind() == "wildcard_import" {
103            facts.wildcard_import_offsets.push(node.start_byte());
104        }
105
106        let mut bound_names = HashSet::<String>::new();
107        if matches!(
108            node.kind(),
109            "function_definition"
110                | "class_definition"
111                | "import_statement"
112                | "import_from_statement"
113                | "assignment"
114                | "augmented_assignment"
115                | "annotated_assignment"
116                | "named_expression"
117                | "for_statement"
118                | "delete_statement"
119                | "as_pattern"
120                | "except_clause"
121                | "case_clause"
122        ) {
123            let mut steps = || {
124                if remaining == 0 || !keep_going() {
125                    false
126                } else {
127                    remaining -= 1;
128                    true
129                }
130            };
131            for binding in
132                crate::bindings::python_direct_scope_bindings_bounded(node, source, &mut steps)?
133            {
134                if binding.declaration.kind() == "identifier" {
135                    bound_names.insert(
136                        binding
137                            .declaration
138                            .utf8_text(source.as_bytes())
139                            .ok()?
140                            .to_owned(),
141                    );
142                }
143            }
144        }
145        if node.kind() == "annotated_assignment"
146            && let Some(target) = node.child_by_field_name("left")
147        {
148            module_target_names(
149                target,
150                source,
151                &mut bound_names,
152                &mut remaining,
153                &mut keep_going,
154            )?;
155        }
156        if node.kind() == "with_statement" {
157            let mut cursor = node.walk();
158            for child in node.named_children(&mut cursor) {
159                if child.kind() == "with_clause" {
160                    let mut with_stack = vec![child];
161                    while let Some(item) = with_stack.pop() {
162                        if remaining == 0 || !keep_going() {
163                            return None;
164                        }
165                        remaining -= 1;
166                        if item.kind() == "as_pattern"
167                            && let Some(alias) = item.child_by_field_name("alias")
168                        {
169                            module_target_names(
170                                alias,
171                                source,
172                                &mut bound_names,
173                                &mut remaining,
174                                &mut keep_going,
175                            )?;
176                        }
177                        with_stack.extend(item.named_children(&mut item.walk()));
178                    }
179                }
180            }
181        }
182
183        if !bound_names.is_empty() {
184            let assignment_initializer = (node.kind() == "assignment"
185                && initializer_path
186                && node.parent().is_some_and(|parent| {
187                    matches!(parent.kind(), "module" | "block" | "expression_statement")
188                })
189                && node.child_by_field_name("type").is_none()
190                && node.child_by_field_name("annotation").is_none()
191                && node
192                    .child_by_field_name("left")
193                    .is_some_and(|left| left.kind() == "identifier")
194                && bound_names.len() == 1)
195                .then(|| node.child_by_field_name("right"))
196                .flatten();
197            for name in bound_names {
198                let binding = facts.bindings.entry(name.into_boxed_str()).or_default();
199                binding.statements = binding.statements.saturating_add(1);
200                if let Some(value) = assignment_initializer {
201                    binding.initializer = Some(node_range(value, source));
202                    binding.initializer_statement_end = node.end_byte();
203                } else {
204                    binding.initializer = None;
205                    binding.initializer_statement_end = node.end_byte();
206                }
207            }
208        }
209
210        let allowed_block = matches!(
211            node.kind(),
212            "if_statement"
213                | "try_statement"
214                | "with_statement"
215                | "for_statement"
216                | "block"
217                | "except_clause"
218                | "else_clause"
219                | "finally_clause"
220                | "expression_statement"
221        );
222        let scope_body = matches!(
223            node.kind(),
224            "function_definition" | "class_definition" | "lambda"
225        )
226        .then(|| node.child_by_field_name("body").map(|body| body.id()))
227        .flatten();
228        let name_node = matches!(node.kind(), "function_definition" | "class_definition")
229            .then(|| node.child_by_field_name("name").map(|name| name.id()))
230            .flatten();
231        let mut cursor = node.walk();
232        let children = node.named_children(&mut cursor).collect::<Vec<_>>();
233        for child in children.into_iter().rev() {
234            if Some(child.id()) == scope_body || Some(child.id()) == name_node {
235                continue;
236            }
237            let child_initializer_path =
238                if matches!(node.kind(), "module" | "block" | "expression_statement") {
239                    initializer_path
240                } else if matches!(
241                    node.kind(),
242                    "except_clause" | "else_clause" | "finally_clause"
243                ) {
244                    initializer_path && child.kind() == "block"
245                } else if allowed_block {
246                    if node.kind() == "try_statement" {
247                        initializer_path
248                            && matches!(
249                                child.kind(),
250                                "block" | "except_clause" | "else_clause" | "finally_clause"
251                            )
252                    } else if matches!(node.kind(), "if_statement" | "for_statement")
253                        && child.kind() == "else_clause"
254                    {
255                        initializer_path
256                    } else {
257                        initializer_path
258                            && child.kind() == "block"
259                            && (node
260                                .child_by_field_name("body")
261                                .is_some_and(|body| body.id() == child.id())
262                                || node
263                                    .child_by_field_name("consequence")
264                                    .is_some_and(|body| body.id() == child.id())
265                                || node
266                                    .child_by_field_name("alternative")
267                                    .is_some_and(|body| body.id() == child.id()))
268                    }
269                } else {
270                    false
271                };
272            if matches!(
273                node.kind(),
274                "list_comprehension"
275                    | "set_comprehension"
276                    | "dictionary_comprehension"
277                    | "generator_expression"
278            ) && child.kind() == "for_in_clause"
279            {
280                let target = child.child_by_field_name("left");
281                let mut clause_cursor = child.walk();
282                for clause_child in child.named_children(&mut clause_cursor) {
283                    if target.is_some_and(|target| target.id() == clause_child.id()) {
284                        continue;
285                    }
286                    stack.push((clause_child, false));
287                }
288                continue;
289            }
290            stack.push((child, child_initializer_path));
291        }
292    }
293
294    remaining = max_nodes;
295    let mut stack = vec![root];
296    while let Some(node) = stack.pop() {
297        if remaining == 0 || !keep_going() {
298            return None;
299        }
300        remaining -= 1;
301        if node.kind() == "global_statement" {
302            let mut cursor = node.walk();
303            for child in node.named_children(&mut cursor) {
304                if child.kind() == "identifier" {
305                    facts.global_declarations.insert(
306                        child
307                            .utf8_text(source.as_bytes())
308                            .ok()?
309                            .to_owned()
310                            .into_boxed_str(),
311                    );
312                }
313            }
314        }
315        if node.kind() == "call" {
316            let function = node.child_by_field_name("function");
317            if function.is_some_and(|function| {
318                function.kind() == "identifier"
319                    && function.utf8_text(source.as_bytes()).ok() == Some("exec")
320            }) {
321                facts.has_exec = true;
322            }
323            if module_globals_write_call(node, source) {
324                facts.has_dynamic_write = true;
325            }
326        }
327        let mut cursor = node.walk();
328        stack.extend(node.named_children(&mut cursor));
329    }
330    facts.wildcard_import_offsets.sort_unstable();
331    Some(facts)
332}
333
334fn node_range(node: Node<'_>, source: &str) -> Range {
335    let start_byte = node.start_byte();
336    let end_byte = node.end_byte();
337    let start_line = source.as_bytes()[..start_byte]
338        .iter()
339        .filter(|byte| **byte == b'\n')
340        .count()
341        + 1;
342    let end_line = source.as_bytes()[..end_byte]
343        .iter()
344        .filter(|byte| **byte == b'\n')
345        .count()
346        + 1;
347    Range {
348        start_byte,
349        end_byte,
350        start_line,
351        end_line,
352    }
353}
354
355fn module_target_names(
356    root: Node<'_>,
357    source: &str,
358    names: &mut HashSet<String>,
359    remaining: &mut usize,
360    keep_going: &mut impl FnMut() -> bool,
361) -> Option<()> {
362    let mut stack = vec![root];
363    while let Some(node) = stack.pop() {
364        if *remaining == 0 || !keep_going() {
365            return None;
366        }
367        *remaining -= 1;
368        match node.kind() {
369            "identifier" => {
370                names.insert(node.utf8_text(source.as_bytes()).ok()?.to_owned());
371            }
372            "attribute" | "subscript" | "call" | "string" | "integer" => {}
373            _ => {
374                let mut cursor = node.walk();
375                stack.extend(node.named_children(&mut cursor));
376            }
377        }
378    }
379    Some(())
380}
381
382fn module_globals_write_call(call: Node<'_>, source: &str) -> bool {
383    let function = call.child_by_field_name("function");
384    let Some(function) = function else {
385        return false;
386    };
387    if function.kind() == "attribute" {
388        let method = function.child_by_field_name("attribute");
389        let object = function.child_by_field_name("object");
390        if method.is_some_and(|method| {
391            method
392                .utf8_text(source.as_bytes())
393                .ok()
394                .is_some_and(|name| matches!(name, "update" | "setdefault" | "pop" | "clear"))
395        }) && object.is_some_and(|object| is_module_mapping_call(object, source))
396        {
397            return true;
398        }
399    }
400    if !is_module_mapping_call(call, source) {
401        return false;
402    }
403    let mut current = call;
404    while let Some(parent) = current.parent() {
405        match parent.kind() {
406            "assignment" | "augmented_assignment" | "annotated_assignment" => {
407                return parent.child_by_field_name("left").is_some_and(|left| {
408                    left.start_byte() <= call.start_byte() && call.end_byte() <= left.end_byte()
409                });
410            }
411            "delete_statement" => return true,
412            "subscript" | "attribute" => current = parent,
413            _ => return false,
414        }
415    }
416    false
417}
418
419fn is_module_mapping_call(node: Node<'_>, source: &str) -> bool {
420    if node.kind() != "call"
421        || !node
422            .child_by_field_name("arguments")
423            .is_some_and(|arguments| {
424                let mut cursor = arguments.walk();
425                arguments.named_children(&mut cursor).next().is_none()
426            })
427    {
428        return false;
429    }
430    node.child_by_field_name("function")
431        .is_some_and(|function| {
432            function.kind() == "identifier"
433                && function
434                    .utf8_text(source.as_bytes())
435                    .ok()
436                    .is_some_and(|name| matches!(name, "globals" | "vars"))
437        })
438}
439
440#[derive(Debug, Clone)]
441pub struct PythonFileSourceFacts {
442    pub occurrences: SourceFactRows,
443    pub facts: PythonSourceFacts,
444}
445
446impl PythonFileSourceFacts {
447    /// Select the canonical callable for one exact current-syntax focus.
448    pub fn callable_return_for_range(
449        &self,
450        start: usize,
451        end: usize,
452        keep_going: &dyn Fn() -> bool,
453    ) -> Option<&brokk_bifrost_core::analyzer::python_facts::PythonCallableReturnFact> {
454        for fact in &self.facts.callable_returns {
455            if !keep_going() {
456                return None;
457            }
458            let declaration = self.occurrences.declaration(fact.declaration);
459            let range = self.occurrences.occurrence(declaration.occurrence).range;
460            if range.start_byte == start && range.end_byte == end {
461                return Some(fact);
462            }
463        }
464        None
465    }
466
467    pub fn estimated_retained_bytes(&self) -> usize {
468        self.occurrences
469            .estimated_bytes()
470            .saturating_add(self.facts.estimated_retained_bytes())
471    }
472}
473
474pub trait PythonSourceFactProvider: Send + Sync {
475    /// Missing publication is unavailable; a published empty file returns Some.
476    /// `keep_going` is the caller's admission and traversal continuation check.
477    fn python_source_facts(
478        &self,
479        token: QueryToken<'_>,
480        file: &ProjectFile,
481        keep_going: &dyn Fn() -> bool,
482    ) -> Option<Arc<PythonFileSourceFacts>>;
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use brokk_bifrost_test_macros::hardwon;
489
490    fn module_facts(source: &str) -> PythonModuleGlobalSourceFacts {
491        let mut parser = tree_sitter::Parser::new();
492        parser
493            .set_language(&tree_sitter_python::LANGUAGE.into())
494            .expect("Python grammar");
495        let tree = parser.parse(source, None).expect("parse fixture");
496        python_module_global_source_facts(tree.root_node(), source, 100_000, || true)
497            .expect("complete Python module facts")
498    }
499
500    fn initializer<'a>(
501        facts: &PythonModuleGlobalSourceFacts,
502        source: &'a str,
503        name: &str,
504    ) -> Option<&'a str> {
505        let range = facts.initializer_range(name)?;
506        source.get(range.start_byte..range.end_byte)
507    }
508
509    #[test]
510    #[hardwon(
511        "#4622: module-global initializer classes became unsafe after rebinding or reflective writes"
512    )]
513    fn module_global_source_fact_requires_a_single_safe_assignment() {
514        let source = "class Target: pass\n\
515safe = Target()\n\
516items = [safe for safe in ()]\n\
517if True:\n    nested_safe = Target()\n";
518        let facts = module_facts(source);
519        assert_eq!(
520            Some("Target()"),
521            initializer(&facts, source, "safe"),
522            "{facts:#?}"
523        );
524        assert_eq!(Some("Target()"), initializer(&facts, source, "nested_safe"));
525
526        let source = "class Target: pass\n\
527global_write = Target()\n\
528def change():\n    global global_write\n    global_write = None\n";
529        assert_eq!(
530            None,
531            initializer(&module_facts(source), source, "global_write")
532        );
533
534        let source = "class Target: pass\n\
535twice = Target()\n\
536twice = Target()\n";
537        assert_eq!(None, initializer(&module_facts(source), source, "twice"));
538
539        let source = "class Target: pass\n\
540dynamic = Target()\n\
541globals()[\"dynamic\"] = None\n";
542        assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
543
544        let source = "class Target: pass\n\
545dynamic = Target()\n\
546vars().update(dynamic=None)\n";
547        assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
548
549        let source = "class Target: pass\n\
550dynamic = Target()\n\
551exec(\"dynamic = None\")\n";
552        assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
553
554        let source = "class Target: pass\n\
555wild = Target()\n\
556from elsewhere import *\n";
557        assert_eq!(None, initializer(&module_facts(source), source, "wild"));
558
559        let source = "class Target: pass\n\
560annotated: object = Target()\n\
561augmented = Target()\n\
562augmented += Target()\n\
563deleted = Target()\n\
564del deleted\n\
565for loop_target in ():\n    pass\n\
566with manager as with_target:\n    pass\n\
567walrus = None\n\
568(walrus := Target())\n\
569import pathlib as imported\n\
570from os import path as from_imported\n\
571def declared():\n    pass\n\
572class Declared: pass\n";
573        let facts = module_facts(source);
574        for name in [
575            "annotated",
576            "augmented",
577            "deleted",
578            "loop_target",
579            "with_target",
580            "walrus",
581            "imported",
582            "from_imported",
583            "declared",
584            "Declared",
585        ] {
586            assert_eq!(
587                None,
588                initializer(&facts, source, name),
589                "{name}: {facts:#?}"
590            );
591        }
592    }
593}