1use crate::graph_support::PythonSource;
29use crate::imports::resolve_imports_batched;
30use brokk_bifrost_core::analyzer::model::{
31 ImportInfo, SemanticAbsenceProof, SemanticDiagnostic, SemanticDiagnosticDomain,
32 SemanticDiagnosticIncompleteReason, SemanticDiagnosticReport, StructuredImportPathKind,
33};
34use brokk_bifrost_core::analyzer::query_token::QueryToken;
35use brokk_bifrost_core::analyzer::semantic_diagnostics::{
36 ScopeStack, contains_node, node_range, node_text, same_node,
37};
38use brokk_bifrost_core::analyzer::structural::facts::Span;
39use brokk_bifrost_core::analyzer::structural::resolution::BoundaryStatus;
40use brokk_bifrost_core::analyzer::tree_walk::{
41 WalkControl, collect_parse_errors, push_named_children_reversed_where_as, walk_tree_preorder,
42};
43use brokk_bifrost_core::analyzer::{BoundedDefinitionLookup, CodeUnit, ProjectFile, Range};
44use brokk_bifrost_core::hash::HashMap;
45use brokk_bifrost_core::text_utils::compute_line_starts;
46use tree_sitter::{Node, Parser};
47
48pub const PYTHON_UNRECOGNIZED_SYMBOL: &str = "python_unrecognized_symbol";
49pub const PYTHON_SEMANTIC_DIAGNOSTIC_SOURCE: &str = "bifrost-python";
50const MAX_PYTHON_SEMANTIC_DIAGNOSTIC_BYTES: usize = 512 * 1024;
51pub const MAX_PYTHON_SEMANTIC_DIAGNOSTICS: usize = 200;
52
53#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum PythonEnvironmentBoundary {
57 Indexed,
59 Absent,
62 Incomplete(SemanticDiagnosticIncompleteReason),
64}
65
66pub trait PythonEnvironmentSurface {
73 fn module_boundary(&self, module_path: &str) -> PythonEnvironmentBoundary;
75
76 fn attribute_boundary(&self, owner_path: &str, member: &str) -> PythonEnvironmentBoundary;
86}
87
88pub fn collect_python_semantic_diagnostics(
90 py: &dyn PythonSource,
91 token: QueryToken<'_>,
92 support: &dyn BoundedDefinitionLookup,
93 environment: &dyn PythonEnvironmentSurface,
94 file: &ProjectFile,
95 source: &str,
96) -> SemanticDiagnosticReport {
97 let mut report = SemanticDiagnosticReport::new();
98 if source.len() > MAX_PYTHON_SEMANTIC_DIAGNOSTIC_BYTES {
99 report.push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
100 return report;
101 }
102 let mut parser = Parser::new();
103 if parser
104 .set_language(&tree_sitter_python::LANGUAGE.into())
105 .is_err()
106 {
107 report.push_incomplete(
108 None,
109 vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
110 detail: "Python parser is unavailable".to_string(),
111 }],
112 );
113 return report;
114 }
115 let Some(tree) = parser.parse(source, None) else {
116 report.push_incomplete(
117 None,
118 vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
119 detail: "Python source did not parse".to_string(),
120 }],
121 );
122 return report;
123 };
124 let mut parse_errors = Vec::new();
125 collect_parse_errors(tree.root_node(), &mut parse_errors);
126 if !parse_errors.is_empty() {
127 report.push_incomplete(
132 None,
133 vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
134 detail: "Python source has parse errors".to_string(),
135 }],
136 );
137 return report;
138 }
139
140 let line_starts = compute_line_starts(source);
141 let imports = py.import_info_of(token, file);
142 let resolved_imports = resolve_imports_batched(py, file, &imports);
143 let dynamic = dynamic_surface_reasons(&imports, &resolved_imports, source, tree.root_node());
144 if !dynamic.is_empty() {
145 report.push_incomplete(None, dynamic);
149 return report;
150 }
151
152 let mut collector = PythonDiagnosticCollector {
153 py,
154 support,
155 environment,
156 file,
157 source,
158 line_starts: &line_starts,
159 module_name: crate::declarations::python_module_name(py.project(), file),
160 module_binders: HashMap::default(),
161 report,
162 };
163 collector.classify_imports(&imports, &resolved_imports);
164 collector.scan_tree(tree.root_node(), &imports, &resolved_imports);
165 collector.report
166}
167
168struct PythonDiagnosticCollector<'a> {
169 py: &'a dyn PythonSource,
170 support: &'a dyn BoundedDefinitionLookup,
171 environment: &'a dyn PythonEnvironmentSurface,
172 file: &'a ProjectFile,
173 source: &'a str,
174 line_starts: &'a [usize],
175 module_name: String,
176 module_binders: HashMap<String, String>,
180 report: SemanticDiagnosticReport,
181}
182
183enum ScanFrame<'tree> {
184 Node(Node<'tree>),
185 ExitScope,
186 SeedTargets(Node<'tree>),
187}
188
189impl PythonDiagnosticCollector<'_> {
190 fn classify_imports(&mut self, imports: &[ImportInfo], resolved: &[Vec<(String, CodeUnit)>]) {
195 debug_assert_eq!(imports.len(), resolved.len());
196 for (import, resolved) in imports.iter().zip(resolved) {
197 let range = import
198 .binder_span
199 .map(|span| span_range(span, self.line_starts));
200 let Some(path) = import.path.as_ref() else {
201 self.report.push_incomplete(
202 range,
203 vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
204 detail: format!(
205 "import `{}` records no parser-derived path",
206 import.raw_snippet
207 ),
208 }],
209 );
210 continue;
211 };
212 let namespace_import = path.kind != Some(StructuredImportPathKind::ImportFrom);
213 if namespace_import && let Some(name) = import.local_name() {
214 let bound = if import.alias.is_some() {
218 path.render_segments(".")
219 } else {
220 path.segments.first().cloned().unwrap_or_default()
221 };
222 if !bound.is_empty() {
223 self.module_binders.insert(name.to_string(), bound);
224 }
225 }
226 if !resolved.is_empty() {
227 if let Some(range) = range {
231 self.report
232 .push_resolved(range, BoundaryStatus::WorkspaceLocal);
233 }
234 continue;
235 }
236 let (owner, boundary) = if namespace_import {
237 let module = path.render_segments(".");
238 let boundary = self.environment.module_boundary(&module);
239 (SemanticDiagnosticDomain::Module { name: module }, boundary)
240 } else if let Some((member, module)) = path.segments.split_last() {
241 let module = module.join(".");
244 let boundary = self.environment.attribute_boundary(&module, member);
245 (SemanticDiagnosticDomain::Module { name: module }, boundary)
246 } else {
247 self.report.push_incomplete(
248 range,
249 vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
250 detail: format!("import `{}` records no path segments", import.raw_snippet),
251 }],
252 );
253 continue;
254 };
255 let Some(range) = range else {
256 self.report.push_incomplete(
259 None,
260 vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
261 detail: format!(
262 "import `{}` binds no single name token",
263 import.raw_snippet
264 ),
265 }],
266 );
267 continue;
268 };
269 self.record_boundary(range, owner, import.raw_snippet.as_str(), boundary);
270 }
271 }
272
273 fn record_boundary(
275 &mut self,
276 range: Range,
277 domain: SemanticDiagnosticDomain,
278 subject: &str,
279 boundary: PythonEnvironmentBoundary,
280 ) {
281 match boundary {
282 PythonEnvironmentBoundary::Indexed => self
283 .report
284 .push_resolved(range, BoundaryStatus::ExternalIndexed),
285 PythonEnvironmentBoundary::Absent => {
286 let message = match &domain {
287 SemanticDiagnosticDomain::MemberSurface { owner, member } => {
291 format!("Unrecognized Python attribute `{member}` on `{owner}`")
292 }
293 _ => format!("Unrecognized Python import `{subject}`"),
294 };
295 self.report.push_absent(
296 SemanticAbsenceProof {
297 range,
298 domain,
299 boundary: BoundaryStatus::ExternalIndexed,
300 },
301 SemanticDiagnostic {
302 range,
303 source: PYTHON_SEMANTIC_DIAGNOSTIC_SOURCE,
304 kind: PYTHON_UNRECOGNIZED_SYMBOL,
305 message,
306 },
307 );
308 }
309 PythonEnvironmentBoundary::Incomplete(reason) => {
310 self.report.push_incomplete(Some(range), vec![reason])
311 }
312 }
313 }
314
315 fn scan_tree(
316 &mut self,
317 root: Node<'_>,
318 imports: &[ImportInfo],
319 resolved: &[Vec<(String, CodeUnit)>],
320 ) {
321 let mut scopes = ScopeStack::default();
322 scopes.enter();
323 self.seed_module_scope(&mut scopes, imports, resolved);
324 let mut stack = vec![ScanFrame::Node(root)];
325 while let Some(frame) = stack.pop() {
326 if self.report.diagnostics().len() >= MAX_PYTHON_SEMANTIC_DIAGNOSTICS {
327 self.report
328 .push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
329 return;
330 }
331 match frame {
332 ScanFrame::Node(node) => self.scan_node(node, &mut scopes, &mut stack),
333 ScanFrame::ExitScope => scopes.exit(),
334 ScanFrame::SeedTargets(node) => self.seed_assignment_targets(node, &mut scopes),
335 }
336 }
337 }
338
339 fn scan_node<'tree>(
340 &mut self,
341 node: Node<'tree>,
342 scopes: &mut ScopeStack,
343 stack: &mut Vec<ScanFrame<'tree>>,
344 ) {
345 match node.kind() {
346 "module" => brokk_bifrost_core::analyzer::tree_walk::push_named_children_reversed_as(
347 node,
348 stack,
349 ScanFrame::Node,
350 ),
351 "function_definition" | "lambda" => {
352 self.seed_named_declaration(node, scopes);
353 scopes.enter();
354 self.seed_parameters(node, scopes);
355 stack.push(ScanFrame::ExitScope);
356 push_named_children_except(stack, node, node.child_by_field_name("name"));
357 }
358 "class_definition" => {
359 self.seed_named_declaration(node, scopes);
360 self.push_field_if_present(stack, node, "superclasses");
361 scopes.enter();
362 stack.push(ScanFrame::ExitScope);
363 if let Some(body) = node.child_by_field_name("body") {
364 stack.push(ScanFrame::Node(body));
365 }
366 }
367 "list_comprehension"
368 | "set_comprehension"
369 | "dictionary_comprehension"
370 | "generator_expression" => {
371 scopes.enter();
372 self.seed_comprehension_targets(node, scopes);
373 stack.push(ScanFrame::ExitScope);
374 brokk_bifrost_core::analyzer::tree_walk::push_named_children_reversed_as(
375 node,
376 stack,
377 ScanFrame::Node,
378 );
379 }
380 "match_statement" => {
381 self.report.push_incomplete(
385 Some(node_range(node, self.line_starts)),
386 vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
387 detail: "match statement pattern bindings are not modeled".to_string(),
388 }],
389 );
390 }
391 "import_statement" | "import_from_statement" => {}
392 "assignment" | "augmented_assignment" | "named_expression" => {
393 stack.push(ScanFrame::SeedTargets(node));
394 self.push_field_if_present(stack, node, "right");
395 self.push_field_if_present(stack, node, "value");
396 }
397 "for_statement" | "for_in_clause" => {
398 if let Some(body) = node.child_by_field_name("body") {
399 stack.push(ScanFrame::Node(body));
400 }
401 stack.push(ScanFrame::SeedTargets(node));
402 self.push_field_if_present(stack, node, "right");
403 }
404 "with_statement" | "with_item" => {
405 stack.push(ScanFrame::SeedTargets(node));
406 brokk_bifrost_core::analyzer::tree_walk::push_named_children_reversed_as(
407 node,
408 stack,
409 ScanFrame::Node,
410 );
411 }
412 "except_clause" => {
413 Self::seed_except_alias(node, self.source, scopes);
414 brokk_bifrost_core::analyzer::tree_walk::push_named_children_reversed_as(
415 node,
416 stack,
417 ScanFrame::Node,
418 );
419 }
420 "identifier" => self.check_identifier(node, scopes),
421 "attribute" => {
422 self.check_attribute(node);
423 if let Some(object) = node.child_by_field_name("object") {
424 stack.push(ScanFrame::Node(object));
425 }
426 }
427 "string" | "string_content" | "comment" => {}
428 _ => brokk_bifrost_core::analyzer::tree_walk::push_named_children_reversed_as(
429 node,
430 stack,
431 ScanFrame::Node,
432 ),
433 }
434 }
435
436 fn seed_module_scope(
437 &self,
438 scopes: &mut ScopeStack,
439 imports: &[ImportInfo],
440 resolved: &[Vec<(String, CodeUnit)>],
441 ) {
442 for import in imports {
443 if let Some(local_name) = import.alias.as_ref().or(import.identifier.as_ref()) {
444 scopes.declare(local_name.clone());
445 }
446 }
447 for binding in resolved.iter().flatten() {
448 scopes.declare(binding.0.clone());
449 }
450 for unit in self.py.declarations(self.file) {
451 if !unit.identifier().is_empty() {
452 scopes.declare(unit.identifier().to_string());
453 }
454 }
455 }
456
457 fn seed_named_declaration(&self, node: Node<'_>, scopes: &mut ScopeStack) {
458 if let Some(name) = node.child_by_field_name("name") {
459 let text = node_text(name, self.source).trim();
460 if !text.is_empty() {
461 scopes.declare(text.to_string());
462 }
463 }
464 }
465
466 fn seed_parameters(&self, node: Node<'_>, scopes: &mut ScopeStack) {
467 if let Some(parameters) = node.child_by_field_name("parameters") {
468 collect_parameter_names(parameters, self.source, scopes);
469 }
470 }
471
472 fn seed_assignment_targets(&self, node: Node<'_>, scopes: &mut ScopeStack) {
473 for field in ["left", "name", "alias"] {
474 if let Some(target) = node.child_by_field_name(field) {
475 collect_bound_identifiers(target, self.source, scopes);
476 }
477 }
478 if node.kind() == "with_item" || node.kind() == "with_statement" {
479 collect_alias_children(node, self.source, scopes);
480 }
481 }
482
483 fn seed_except_alias(node: Node<'_>, source: &str, scopes: &mut ScopeStack) {
484 collect_alias_children(node, source, scopes);
488 }
489
490 fn seed_comprehension_targets(&self, node: Node<'_>, scopes: &mut ScopeStack) {
491 let mut stack = vec![node];
492 while let Some(current) = stack.pop() {
493 if matches!(current.kind(), "for_statement" | "for_in_clause")
494 && let Some(left) = current.child_by_field_name("left")
495 {
496 collect_bound_identifiers(left, self.source, scopes);
497 }
498 let mut cursor = current.walk();
499 for child in current.named_children(&mut cursor) {
500 stack.push(child);
501 }
502 }
503 }
504
505 fn check_identifier(&mut self, node: Node<'_>, scopes: &ScopeStack) {
506 if !self.is_reference_identifier(node) {
507 return;
508 }
509 let name = node_text(node, self.source);
510 if name.is_empty() || name == "_" {
513 return;
514 }
515 let range = node_range(node, self.line_starts);
516 if is_python_builtin_or_constant(name) {
517 self.report
520 .push_resolved(range, BoundaryStatus::ExternalIndexed);
521 return;
522 }
523 if scopes.contains(name) || self.name_resolves_project_locally(name) {
524 self.report
525 .push_resolved(range, BoundaryStatus::WorkspaceLocal);
526 return;
527 }
528 self.report.push_absent(
532 SemanticAbsenceProof {
533 range,
534 domain: SemanticDiagnosticDomain::LexicalScope {
535 file: self.file.rel_path().to_path_buf(),
536 range,
537 },
538 boundary: BoundaryStatus::WorkspaceLocal,
539 },
540 SemanticDiagnostic {
541 range,
542 source: PYTHON_SEMANTIC_DIAGNOSTIC_SOURCE,
543 kind: PYTHON_UNRECOGNIZED_SYMBOL,
544 message: format!("Unrecognized Python symbol `{name}`"),
545 },
546 );
547 }
548
549 fn check_attribute(&mut self, node: Node<'_>) {
551 let Some((owner, member)) = self.module_attribute_target(node) else {
552 return;
553 };
554 let Some(attribute) = node.child_by_field_name("attribute") else {
555 return;
556 };
557 let range = node_range(attribute, self.line_starts);
558 let boundary = self.environment.attribute_boundary(&owner, &member);
559 self.record_boundary(
560 range,
561 SemanticDiagnosticDomain::MemberSurface {
562 owner,
563 member: member.clone(),
564 },
565 &member,
566 boundary,
567 );
568 }
569
570 fn module_attribute_target(&self, node: Node<'_>) -> Option<(String, String)> {
580 let member = node_text(node.child_by_field_name("attribute")?, self.source);
581 if member.is_empty() {
582 return None;
583 }
584 let mut intermediate = Vec::new();
585 let mut current = node.child_by_field_name("object")?;
586 while current.kind() == "attribute" {
587 intermediate.push(node_text(
588 current.child_by_field_name("attribute")?,
589 self.source,
590 ));
591 current = current.child_by_field_name("object")?;
592 }
593 if current.kind() != "identifier" {
594 return None;
595 }
596 let mut owner = self
597 .module_binders
598 .get(node_text(current, self.source))?
599 .clone();
600 for segment in intermediate.iter().rev() {
601 owner.push('.');
602 owner.push_str(segment);
603 }
604 Some((owner, member.to_string()))
605 }
606
607 fn is_reference_identifier(&self, node: Node<'_>) -> bool {
608 if is_declaration_identifier(node)
609 || is_import_identifier(node)
610 || is_attribute_identifier(node)
611 || is_pattern_identifier(node)
612 {
613 return false;
614 }
615 let mut current = node;
616 while let Some(parent) = current.parent() {
617 if matches!(parent.kind(), "string" | "string_content" | "comment") {
618 return false;
619 }
620 current = parent;
621 }
622 true
623 }
624
625 fn name_resolves_project_locally(&self, name: &str) -> bool {
626 if !self.support.file_identifier(self.file, name).is_empty() {
627 return true;
628 }
629 if !self
630 .support
631 .fqn(&format!("{}.{}", self.module_name, name))
632 .is_empty()
633 {
634 return true;
635 }
636 false
637 }
638
639 fn push_field_if_present<'tree>(
640 &self,
641 stack: &mut Vec<ScanFrame<'tree>>,
642 node: Node<'tree>,
643 field_name: &str,
644 ) {
645 if let Some(child) = node.child_by_field_name(field_name) {
646 stack.push(ScanFrame::Node(child));
647 }
648 }
649}
650
651fn span_range(span: Span, line_starts: &[usize]) -> Range {
652 brokk_bifrost_core::analyzer::semantic_diagnostics::range_for_byte_span(
653 line_starts,
654 span.start_byte,
655 span.end_byte,
656 )
657}
658
659fn dynamic_surface_reasons(
663 imports: &[ImportInfo],
664 resolved: &[Vec<(String, CodeUnit)>],
665 source: &str,
666 root: Node<'_>,
667) -> Vec<SemanticDiagnosticIncompleteReason> {
668 debug_assert_eq!(imports.len(), resolved.len());
669 let mut reasons = Vec::new();
670 for (import, resolved) in imports.iter().zip(resolved) {
671 if import.is_wildcard && resolved.is_empty() {
672 reasons.push(SemanticDiagnosticIncompleteReason::DynamicBehavior {
673 detail: format!(
674 "`{}` binds an unknown set of names",
675 import.raw_snippet.trim()
676 ),
677 });
678 }
679 }
680 if has_module_getattr(source, root) {
681 reasons.push(SemanticDiagnosticIncompleteReason::DynamicBehavior {
682 detail: "the module defines `__getattr__`".to_string(),
683 });
684 }
685 for call in dynamic_namespace_calls(source, root) {
686 reasons.push(SemanticDiagnosticIncompleteReason::DynamicBehavior {
687 detail: format!("the module calls `{call}`"),
688 });
689 }
690 reasons
691}
692
693fn has_module_getattr(source: &str, root: Node<'_>) -> bool {
694 let mut cursor = root.walk();
695 root.named_children(&mut cursor).any(|child| {
696 let child = if child.kind() == "decorated_definition" {
697 child
698 .child_by_field_name("definition")
699 .expect("decorated definition has a definition")
700 } else {
701 child
702 };
703 child.kind() == "function_definition"
704 && child
705 .child_by_field_name("name")
706 .is_some_and(|name| node_text(name, source) == "__getattr__")
707 })
708}
709
710fn dynamic_namespace_calls(source: &str, root: Node<'_>) -> Vec<String> {
712 let mut calls: Vec<String> = Vec::new();
713 walk_tree_preorder(root, true, |node| {
714 if node.kind() == "call"
715 && let Some(function) = node.child_by_field_name("function")
716 && let Some(name) = dynamic_function_name(function, source)
717 && !calls.iter().any(|seen| seen == name)
718 {
719 calls.push(name.to_string());
720 }
721 WalkControl::Continue
722 });
723 calls
724}
725
726fn dynamic_function_name<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> {
727 let text = node_text(node, source);
728 match node.kind() {
729 "identifier" => matches!(text, "globals" | "locals" | "__import__").then_some(text),
730 "attribute" => (text == "importlib.import_module").then_some(text),
731 _ => None,
732 }
733}
734
735fn collect_bound_identifiers(node: Node<'_>, source: &str, scopes: &mut ScopeStack) {
736 let mut stack = vec![node];
737 while let Some(current) = stack.pop() {
738 match current.kind() {
739 "identifier" => {
740 let text = node_text(current, source).trim();
741 if !text.is_empty() {
742 scopes.declare(text.to_string());
743 }
744 }
745 "attribute" | "call" => {}
746 _ => {
747 let mut cursor = current.walk();
748 for child in current.named_children(&mut cursor) {
749 stack.push(child);
750 }
751 }
752 }
753 }
754}
755
756fn collect_parameter_names(node: Node<'_>, source: &str, scopes: &mut ScopeStack) {
757 let mut cursor = node.walk();
758 for child in node.named_children(&mut cursor) {
759 if let Some(name) = python_parameter_name(child, source) {
760 scopes.declare(name);
761 }
762 }
763}
764
765use crate::syntax::python_parameter_name;
766
767fn collect_alias_children(node: Node<'_>, source: &str, scopes: &mut ScopeStack) {
768 let mut cursor = node.walk();
769 for alias in node.children_by_field_name("alias", &mut cursor) {
770 collect_bound_identifiers(alias, source, scopes);
771 }
772 let mut cursor = node.walk();
773 for item in node.named_children(&mut cursor) {
774 let mut item_cursor = item.walk();
775 for alias in item.children_by_field_name("alias", &mut item_cursor) {
776 collect_bound_identifiers(alias, source, scopes);
777 }
778 }
779}
780
781fn push_named_children_except<'tree>(
782 stack: &mut Vec<ScanFrame<'tree>>,
783 node: Node<'tree>,
784 excluded: Option<Node<'tree>>,
785) {
786 push_named_children_reversed_where_as(
787 node,
788 stack,
789 |child| excluded.is_none_or(|excluded| !same_node(child, excluded)),
790 ScanFrame::Node,
791 );
792}
793
794fn is_declaration_identifier(node: Node<'_>) -> bool {
795 let Some(parent) = node.parent() else {
796 return false;
797 };
798 match parent.kind() {
799 "function_definition" | "class_definition" => parent
800 .child_by_field_name("name")
801 .is_some_and(|name| same_node(name, node)),
802 "parameters" | "lambda_parameters" | "list_splat_pattern" | "dictionary_splat_pattern" => {
803 true
804 }
805 "default_parameter" | "typed_parameter" | "typed_default_parameter" => parent
806 .child_by_field_name("name")
807 .is_some_and(|name| contains_node(name, node)),
808 "assignment" | "augmented_assignment" | "for_statement" | "for_in_clause" => parent
809 .child_by_field_name("left")
810 .is_some_and(|left| contains_node(left, node)),
811 "named_expression" => parent
812 .child_by_field_name("name")
813 .is_some_and(|name| contains_node(name, node)),
814 _ => false,
815 }
816}
817
818fn is_import_identifier(node: Node<'_>) -> bool {
819 let mut current = node;
820 while let Some(parent) = current.parent() {
821 if matches!(parent.kind(), "import_statement" | "import_from_statement") {
822 return true;
823 }
824 current = parent;
825 }
826 false
827}
828
829fn is_attribute_identifier(node: Node<'_>) -> bool {
830 let Some(parent) = node.parent() else {
831 return false;
832 };
833 parent.kind() == "attribute"
834 && parent
835 .child_by_field_name("attribute")
836 .is_some_and(|attribute| same_node(attribute, node))
837}
838
839fn is_pattern_identifier(node: Node<'_>) -> bool {
840 let mut current = node;
841 while let Some(parent) = current.parent() {
842 match parent.kind() {
846 "as_pattern" => {
847 return parent
848 .child_by_field_name("alias")
849 .is_some_and(|alias| contains_node(alias, node));
850 }
851 "assignment" | "augmented_assignment" | "for_statement" | "for_in_clause" => {
852 return parent
853 .child_by_field_name("left")
854 .is_some_and(|left| contains_node(left, node));
855 }
856 "list_splat_pattern" | "dictionary_splat_pattern" => return true,
857 "call"
858 | "attribute"
859 | "subscript"
860 | "function_definition"
861 | "class_definition"
862 | "lambda" => return false,
863 _ => {}
864 }
865 current = parent;
866 }
867 false
868}
869
870pub fn is_python_builtin_or_constant(name: &str) -> bool {
871 matches!(
872 name,
873 "None"
874 | "True"
875 | "False"
876 | "NotImplemented"
877 | "Ellipsis"
878 | "__annotations__"
879 | "__builtins__"
880 | "__debug__"
881 | "__doc__"
882 | "__file__"
883 | "__loader__"
884 | "__name__"
885 | "__package__"
886 | "__spec__"
887 | "ArithmeticError"
888 | "AssertionError"
889 | "AttributeError"
890 | "BaseException"
891 | "BaseExceptionGroup"
892 | "BlockingIOError"
893 | "BrokenPipeError"
894 | "BufferError"
895 | "BytesWarning"
896 | "ChildProcessError"
897 | "ConnectionAbortedError"
898 | "ConnectionError"
899 | "ConnectionRefusedError"
900 | "ConnectionResetError"
901 | "DeprecationWarning"
902 | "EOFError"
903 | "EncodingWarning"
904 | "EnvironmentError"
905 | "Exception"
906 | "ExceptionGroup"
907 | "FileExistsError"
908 | "FileNotFoundError"
909 | "FloatingPointError"
910 | "FutureWarning"
911 | "GeneratorExit"
912 | "IOError"
913 | "ImportError"
914 | "ImportWarning"
915 | "IndentationError"
916 | "IndexError"
917 | "InterruptedError"
918 | "IsADirectoryError"
919 | "KeyError"
920 | "KeyboardInterrupt"
921 | "LookupError"
922 | "MemoryError"
923 | "ModuleNotFoundError"
924 | "NameError"
925 | "NotADirectoryError"
926 | "NotImplementedError"
927 | "OSError"
928 | "OverflowError"
929 | "PendingDeprecationWarning"
930 | "PermissionError"
931 | "ProcessLookupError"
932 | "RecursionError"
933 | "ReferenceError"
934 | "ResourceWarning"
935 | "RuntimeError"
936 | "RuntimeWarning"
937 | "StopAsyncIteration"
938 | "StopIteration"
939 | "SyntaxError"
940 | "SyntaxWarning"
941 | "SystemError"
942 | "SystemExit"
943 | "TabError"
944 | "TimeoutError"
945 | "TypeError"
946 | "UnboundLocalError"
947 | "UnicodeDecodeError"
948 | "UnicodeEncodeError"
949 | "UnicodeError"
950 | "UnicodeTranslateError"
951 | "UnicodeWarning"
952 | "UserWarning"
953 | "ValueError"
954 | "Warning"
955 | "ZeroDivisionError"
956 | "abs"
957 | "aiter"
958 | "all"
959 | "anext"
960 | "any"
961 | "ascii"
962 | "bin"
963 | "bool"
964 | "breakpoint"
965 | "bytearray"
966 | "bytes"
967 | "callable"
968 | "chr"
969 | "classmethod"
970 | "compile"
971 | "complex"
972 | "copyright"
973 | "credits"
974 | "delattr"
975 | "dict"
976 | "dir"
977 | "divmod"
978 | "enumerate"
979 | "eval"
980 | "exec"
981 | "exit"
982 | "filter"
983 | "float"
984 | "format"
985 | "frozenset"
986 | "getattr"
987 | "globals"
988 | "hasattr"
989 | "hash"
990 | "help"
991 | "hex"
992 | "id"
993 | "input"
994 | "int"
995 | "isinstance"
996 | "issubclass"
997 | "iter"
998 | "len"
999 | "license"
1000 | "list"
1001 | "locals"
1002 | "map"
1003 | "max"
1004 | "memoryview"
1005 | "min"
1006 | "next"
1007 | "object"
1008 | "oct"
1009 | "open"
1010 | "ord"
1011 | "pow"
1012 | "print"
1013 | "property"
1014 | "quit"
1015 | "range"
1016 | "repr"
1017 | "reversed"
1018 | "round"
1019 | "set"
1020 | "setattr"
1021 | "slice"
1022 | "sorted"
1023 | "staticmethod"
1024 | "str"
1025 | "sum"
1026 | "super"
1027 | "tuple"
1028 | "type"
1029 | "vars"
1030 | "zip"
1031 | "__import__"
1032 | "apply"
1036 | "basestring"
1037 | "buffer"
1038 | "cmp"
1039 | "coerce"
1040 | "execfile"
1041 | "intern"
1042 | "long"
1043 | "raw_input"
1044 | "reduce"
1045 | "reload"
1046 | "unichr"
1047 | "unicode"
1048 | "xrange"
1049 )
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054 use super::*;
1055 use brokk_bifrost_test_macros::hardwon;
1056
1057 #[test]
1058 #[hardwon(
1059 "6b123df7cc68: quicksilver round 6 on brokk-bifrost-python: lambda parameter declarations were judged as reads"
1060 )]
1061 fn qs6_lexical_binder_grammar_boundary() {
1062 for (source, spelling, declaration, pattern) in [
1063 ("def run(self): pass\n", "self", true, false),
1064 ("identity = lambda local: 1\n", "local", true, false),
1065 ("def run(*args): pass\n", "args", true, true),
1066 ("with manager() as alias: pass\n", "alias", false, true),
1067 ("result = missing, *items\n", "missing", false, false),
1068 ("missing, *items = source\n", "missing", false, true),
1069 ("result = (missing, items)\n", "missing", false, false),
1070 ] {
1071 let tree = crate::declarations::parse_python_tree(source).unwrap();
1072 assert!(!tree.root_node().has_error());
1073 let mut pending = vec![tree.root_node()];
1074 let mut found = false;
1075 while let Some(node) = pending.pop() {
1076 if node.kind() == "identifier" && node_text(node, source) == spelling {
1077 assert_eq!(
1078 is_declaration_identifier(node),
1079 declaration,
1080 "{source}: {}",
1081 tree.root_node().to_sexp()
1082 );
1083 assert_eq!(
1084 is_pattern_identifier(node),
1085 pattern,
1086 "{source}: {}",
1087 tree.root_node().to_sexp()
1088 );
1089 found = true;
1090 }
1091 let mut cursor = node.walk();
1092 pending.extend(node.named_children(&mut cursor));
1093 }
1094 assert!(found);
1095 }
1096 }
1097
1098 #[test]
1099 #[hardwon(
1100 "6b123df7cc68: quicksilver round 6 on brokk-bifrost-python: globals builtin reference was falsely absent"
1101 )]
1102 fn qs6_cpython_builtin_inventory() {
1103 let names = "ArithmeticError AssertionError AttributeError BaseException BlockingIOError BrokenPipeError BufferError BytesWarning ChildProcessError ConnectionAbortedError ConnectionError ConnectionRefusedError ConnectionResetError DeprecationWarning EOFError Ellipsis EnvironmentError Exception False FileExistsError FileNotFoundError FloatingPointError FutureWarning GeneratorExit IOError ImportError ImportWarning IndentationError IndexError InterruptedError IsADirectoryError KeyError KeyboardInterrupt LookupError MemoryError ModuleNotFoundError NameError None NotADirectoryError NotImplemented NotImplementedError OSError OverflowError PendingDeprecationWarning PermissionError ProcessLookupError RecursionError ReferenceError ResourceWarning RuntimeError RuntimeWarning StopAsyncIteration StopIteration SyntaxError SyntaxWarning SystemError SystemExit TabError TimeoutError True TypeError UnboundLocalError UnicodeDecodeError UnicodeEncodeError UnicodeError UnicodeTranslateError UnicodeWarning UserWarning ValueError Warning ZeroDivisionError __debug__ __import__ abs all any ascii bin bool breakpoint bytearray bytes callable chr classmethod compile complex copyright credits delattr dict dir divmod enumerate eval exec exit filter float format frozenset getattr globals hasattr hash help hex id input int isinstance issubclass iter len license list locals map max memoryview min next object oct open ord pow print property quit range repr reversed round set setattr slice sorted staticmethod str sum super tuple type vars zip";
1107 for name in names.split_whitespace().chain([
1108 "__annotations__",
1109 "__builtins__",
1110 "__doc__",
1111 "__file__",
1112 "__loader__",
1113 "__name__",
1114 "__package__",
1115 "__spec__",
1116 "apply",
1117 "basestring",
1118 "buffer",
1119 "cmp",
1120 "coerce",
1121 "execfile",
1122 "intern",
1123 "long",
1124 "raw_input",
1125 "reduce",
1126 "reload",
1127 "unichr",
1128 "unicode",
1129 "xrange",
1130 ]) {
1131 assert!(
1132 is_python_builtin_or_constant(name),
1133 "missing builtin {name}"
1134 );
1135 assert!(
1136 !is_python_builtin_or_constant(&format!("{name}_")),
1137 "invented builtin {name}_"
1138 );
1139 }
1140 }
1141
1142 #[test]
1143 #[hardwon(
1144 "6b123df7cc68: quicksilver round 6 on brokk-bifrost-python: decorated module __getattr__ was missed"
1145 )]
1146 fn qs6_dynamic_surface_grammar_matrix() {
1147 for (source, dynamic) in [
1148 ("", false),
1149 ("value = 1\n", false),
1150 ("value = 1\ndef __getattr__(name): return None\n", true),
1151 ("@decorate\ndef __getattr__(name): return None\n", true),
1152 (
1153 "class C:\n def __getattr__(self, name): return None\n",
1154 false,
1155 ),
1156 ("globals()['injected'] = 1\nprint(injected)\n", true),
1157 ] {
1158 let tree = crate::declarations::parse_python_tree(source).unwrap();
1159 assert!(!tree.root_node().has_error());
1160 let reasons = dynamic_surface_reasons(&[], &[], source, tree.root_node());
1161 assert_eq!(!reasons.is_empty(), dynamic, "{source}: {reasons:?}");
1162 }
1163 }
1164
1165 #[test]
1166 #[hardwon("83a867bd9573: exception alias seeding selected handler-body identifiers")]
1167 fn qs6_exception_and_with_alias_fields_seed_only_binders() {
1168 let source = "# \u{e9}\ntry:\n work()\nexcept Error as err:\n body(observed)\ntry:\n work()\nexcept (One, Two) as other:\n body(observed)\nwith manager() as value, other_manager() as (left, right):\n body(value)\n";
1169 let tree = crate::declarations::parse_python_tree(source).unwrap();
1170 assert!(!tree.root_node().has_error());
1171 let mut pending = vec![tree.root_node()];
1172 let mut seen = Vec::new();
1173 while let Some(node) = pending.pop() {
1174 if matches!(node.kind(), "except_clause" | "with_item") {
1175 let mut scopes = ScopeStack::default();
1176 if node.kind() == "except_clause" {
1177 PythonDiagnosticCollector::seed_except_alias(node, source, &mut scopes);
1178 } else {
1179 collect_alias_children(node, source, &mut scopes);
1180 }
1181 let expected: &[&str] = match node.kind() {
1182 "except_clause" if node_text(node, source).starts_with("except Error") => {
1183 &["err"]
1184 }
1185 "except_clause" => &["other"],
1186 _ if node_text(node, source).starts_with("manager()") => &["value"],
1187 _ => &["left", "right"],
1188 };
1189 let pattern = node.child_by_field_name("value").unwrap();
1190 assert_eq!(pattern.kind(), "as_pattern");
1191 let alias = pattern.child_by_field_name("alias").unwrap();
1192 assert_eq!(alias.kind(), "as_pattern_target");
1193 let spelling = if expected == ["left", "right"] {
1194 "(left, right)"
1195 } else {
1196 expected[0]
1197 };
1198 let start = source.find(&format!("as {spelling}")).unwrap() + 3;
1199 assert_eq!(alias.byte_range(), start..start + spelling.len());
1200 assert_eq!(node_text(alias, source), spelling);
1201 for name in [
1202 "err",
1203 "other",
1204 "value",
1205 "left",
1206 "right",
1207 "Error",
1208 "One",
1209 "Two",
1210 "body",
1211 "manager",
1212 "other_manager",
1213 "observed",
1214 ] {
1215 assert_eq!(
1216 scopes.contains(name),
1217 expected.contains(&name),
1218 "{name}: {}",
1219 node.to_sexp()
1220 );
1221 }
1222 seen.push(node.kind());
1223 }
1224 let mut cursor = node.walk();
1225 pending.extend(node.named_children(&mut cursor));
1226 }
1227 assert_eq!(
1228 seen.iter().filter(|&&kind| kind == "except_clause").count(),
1229 2
1230 );
1231 assert_eq!(seen.iter().filter(|&&kind| kind == "with_item").count(), 2);
1232 }
1233}