1use 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#[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 pub fn module_binds_name(&self, name: &str) -> bool {
39 self.bindings.contains_key(name)
40 }
41
42 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 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
78pub 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 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 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}