1use crate::graph_support::PythonSource;
9use crate::imports::{resolve_import, resolve_import_bindings};
10use brokk_bifrost_core::analyzer::model::SemanticDiagnostic;
11use brokk_bifrost_core::analyzer::semantic_diagnostics::{
12 ScopeStack, contains_node, node_range, node_text, same_node,
13};
14use brokk_bifrost_core::analyzer::tree_walk::{collect_parse_errors, subtree_contains};
15use brokk_bifrost_core::analyzer::{BoundedDefinitionLookup, ProjectFile, Range};
16use brokk_bifrost_core::text_utils::compute_line_starts;
17use tree_sitter::{Node, Parser, Tree};
18
19pub const PYTHON_UNRECOGNIZED_SYMBOL: &str = "python_unrecognized_symbol";
20pub const PYTHON_SEMANTIC_DIAGNOSTIC_SOURCE: &str = "bifrost-python";
21const MAX_PYTHON_SEMANTIC_DIAGNOSTIC_BYTES: usize = 512 * 1024;
22pub const MAX_PYTHON_SEMANTIC_DIAGNOSTICS: usize = 200;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct PythonSemanticDiagnostic {
26 pub range: Range,
27 pub kind: &'static str,
28 pub message: String,
29}
30
31impl From<PythonSemanticDiagnostic> for SemanticDiagnostic {
32 fn from(diagnostic: PythonSemanticDiagnostic) -> Self {
33 Self {
34 range: diagnostic.range,
35 source: PYTHON_SEMANTIC_DIAGNOSTIC_SOURCE,
36 kind: diagnostic.kind,
37 message: diagnostic.message,
38 }
39 }
40}
41
42pub fn collect_python_semantic_diagnostics(
51 py: &dyn PythonSource,
52 support: &dyn BoundedDefinitionLookup,
53 file: &ProjectFile,
54 source: &str,
55) -> Vec<PythonSemanticDiagnostic> {
56 if source.len() > MAX_PYTHON_SEMANTIC_DIAGNOSTIC_BYTES {
57 return Vec::new();
58 }
59 let Some(tree) = parse_python_tree(source) else {
60 return Vec::new();
61 };
62 let mut parse_errors = Vec::new();
63 collect_parse_errors(tree.root_node(), &mut parse_errors);
64 if !parse_errors.is_empty() || file_has_dynamic_unknowns(py, file, source, tree.root_node()) {
65 return Vec::new();
66 }
67
68 let line_starts = compute_line_starts(source);
69 let module_name = crate::declarations::python_module_name(file);
70 let mut collector = PythonDiagnosticCollector {
71 py,
72 support,
73 file,
74 source,
75 line_starts: &line_starts,
76 module_name,
77 diagnostics: Vec::new(),
78 };
79 collector.scan_tree(tree.root_node());
80 collector.diagnostics
81}
82
83fn parse_python_tree(source: &str) -> Option<Tree> {
84 let mut parser = Parser::new();
85 parser
86 .set_language(&tree_sitter_python::LANGUAGE.into())
87 .ok()?;
88 parser.parse(source, None)
89}
90
91struct PythonDiagnosticCollector<'a> {
92 py: &'a dyn PythonSource,
93 support: &'a dyn BoundedDefinitionLookup,
94 file: &'a ProjectFile,
95 source: &'a str,
96 line_starts: &'a [usize],
97 module_name: String,
98 diagnostics: Vec<PythonSemanticDiagnostic>,
99}
100
101enum ScanFrame<'tree> {
102 Node(Node<'tree>),
103 ExitScope,
104 SeedTargets(Node<'tree>),
105}
106
107impl PythonDiagnosticCollector<'_> {
108 fn scan_tree(&mut self, root: Node<'_>) {
109 let mut scopes = ScopeStack::default();
110 scopes.enter();
111 self.seed_module_scope(&mut scopes);
112 let mut stack = vec![ScanFrame::Node(root)];
113 while let Some(frame) = stack.pop() {
114 if self.diagnostics.len() >= MAX_PYTHON_SEMANTIC_DIAGNOSTICS {
115 break;
116 }
117 match frame {
118 ScanFrame::Node(node) => self.scan_node(node, &mut scopes, &mut stack),
119 ScanFrame::ExitScope => scopes.exit(),
120 ScanFrame::SeedTargets(node) => self.seed_assignment_targets(node, &mut scopes),
121 }
122 }
123 }
124
125 fn scan_node<'tree>(
126 &mut self,
127 node: Node<'tree>,
128 scopes: &mut ScopeStack,
129 stack: &mut Vec<ScanFrame<'tree>>,
130 ) {
131 match node.kind() {
132 "module" => push_named_children(stack, node),
133 "function_definition" | "lambda" => {
134 self.seed_named_declaration(node, scopes);
135 scopes.enter();
136 self.seed_parameters(node, scopes);
137 stack.push(ScanFrame::ExitScope);
138 push_named_children_except(stack, node, node.child_by_field_name("name"));
139 }
140 "class_definition" => {
141 self.seed_named_declaration(node, scopes);
142 self.push_field_if_present(stack, node, "superclasses");
143 scopes.enter();
144 stack.push(ScanFrame::ExitScope);
145 if let Some(body) = node.child_by_field_name("body") {
146 stack.push(ScanFrame::Node(body));
147 }
148 }
149 "list_comprehension"
150 | "set_comprehension"
151 | "dictionary_comprehension"
152 | "generator_expression" => {
153 scopes.enter();
154 self.seed_comprehension_targets(node, scopes);
155 stack.push(ScanFrame::ExitScope);
156 push_named_children(stack, node);
157 }
158 "match_statement" => {}
159 "import_statement" | "import_from_statement" => {}
160 "assignment" | "augmented_assignment" | "named_expression" => {
161 stack.push(ScanFrame::SeedTargets(node));
162 self.push_field_if_present(stack, node, "right");
163 self.push_field_if_present(stack, node, "value");
164 }
165 "for_statement" | "for_in_clause" => {
166 if let Some(body) = node.child_by_field_name("body") {
167 stack.push(ScanFrame::Node(body));
168 }
169 stack.push(ScanFrame::SeedTargets(node));
170 self.push_field_if_present(stack, node, "right");
171 }
172 "with_statement" | "with_item" => {
173 stack.push(ScanFrame::SeedTargets(node));
174 push_named_children(stack, node);
175 }
176 "except_clause" => {
177 self.seed_except_alias(node, scopes);
178 push_named_children(stack, node);
179 }
180 "identifier" => self.check_identifier(node, scopes),
181 "attribute" => {
182 if let Some(object) = node.child_by_field_name("object") {
183 stack.push(ScanFrame::Node(object));
184 }
185 }
186 "string" | "string_content" | "comment" => {}
187 _ => push_named_children(stack, node),
188 }
189 }
190
191 fn seed_module_scope(&self, scopes: &mut ScopeStack) {
192 for import in self.py.import_info_of(self.file) {
193 if let Some(local_name) = import.alias.as_ref().or(import.identifier.as_ref()) {
194 scopes.declare(local_name.clone());
195 }
196 }
197 for (binding, _) in resolve_import_bindings(self.py, self.file) {
198 scopes.declare(binding);
199 }
200 for unit in self.py.declarations(self.file) {
201 if !unit.identifier().is_empty() {
202 scopes.declare(unit.identifier().to_string());
203 }
204 }
205 }
206
207 fn seed_named_declaration(&self, node: Node<'_>, scopes: &mut ScopeStack) {
208 if let Some(name) = node.child_by_field_name("name") {
209 let text = node_text(name, self.source).trim();
210 if !text.is_empty() {
211 scopes.declare(text.to_string());
212 }
213 }
214 }
215
216 fn seed_parameters(&self, node: Node<'_>, scopes: &mut ScopeStack) {
217 if let Some(parameters) = node.child_by_field_name("parameters") {
218 collect_parameter_names(parameters, self.source, scopes);
219 }
220 }
221
222 fn seed_assignment_targets(&self, node: Node<'_>, scopes: &mut ScopeStack) {
223 for field in ["left", "name", "alias"] {
224 if let Some(target) = node.child_by_field_name(field) {
225 collect_bound_identifiers(target, self.source, scopes);
226 }
227 }
228 if node.kind() == "with_item" || node.kind() == "with_statement" {
229 collect_alias_children(node, self.source, scopes);
230 }
231 }
232
233 fn seed_except_alias(&self, node: Node<'_>, scopes: &mut ScopeStack) {
234 if let Some(alias) = node.child_by_field_name("alias") {
235 collect_bound_identifiers(alias, self.source, scopes);
236 return;
237 }
238 let mut identifiers = Vec::new();
239 let mut stack = vec![node];
240 while let Some(current) = stack.pop() {
241 if current.kind() == "identifier" {
242 let text = node_text(current, self.source).trim();
243 if !text.is_empty() {
244 identifiers.push(text.to_string());
245 }
246 continue;
247 }
248 let mut cursor = current.walk();
249 for child in current.named_children(&mut cursor) {
250 stack.push(child);
251 }
252 }
253 if identifiers.len() >= 2
254 && let Some(alias) = identifiers.into_iter().next()
255 {
256 scopes.declare(alias);
257 }
258 }
259
260 fn seed_comprehension_targets(&self, node: Node<'_>, scopes: &mut ScopeStack) {
261 let mut stack = vec![node];
262 while let Some(current) = stack.pop() {
263 if matches!(current.kind(), "for_statement" | "for_in_clause")
264 && let Some(left) = current.child_by_field_name("left")
265 {
266 collect_bound_identifiers(left, self.source, scopes);
267 }
268 let mut cursor = current.walk();
269 for child in current.named_children(&mut cursor) {
270 stack.push(child);
271 }
272 }
273 }
274
275 fn check_identifier(&mut self, node: Node<'_>, scopes: &ScopeStack) {
276 if !self.is_reference_identifier(node) {
277 return;
278 }
279 let name = node_text(node, self.source);
280 if name.is_empty() || name == "_" || is_python_builtin_or_constant(name) {
281 return;
282 }
283 if scopes.contains(name) || self.name_resolves_project_locally(name) {
284 return;
285 }
286 self.diagnostics.push(PythonSemanticDiagnostic {
287 range: node_range(node, self.line_starts),
288 kind: PYTHON_UNRECOGNIZED_SYMBOL,
289 message: format!("Unrecognized Python symbol `{name}`"),
290 });
291 }
292
293 fn is_reference_identifier(&self, node: Node<'_>) -> bool {
294 if is_declaration_identifier(node)
295 || is_import_identifier(node)
296 || is_attribute_identifier(node)
297 || is_pattern_identifier(node)
298 {
299 return false;
300 }
301 let mut current = node;
302 while let Some(parent) = current.parent() {
303 if matches!(parent.kind(), "string" | "string_content" | "comment") {
304 return false;
305 }
306 current = parent;
307 }
308 true
309 }
310
311 fn name_resolves_project_locally(&self, name: &str) -> bool {
312 if !self.support.file_identifier(self.file, name).is_empty() {
313 return true;
314 }
315 if !self
316 .support
317 .fqn(&format!("{}.{}", self.module_name, name))
318 .is_empty()
319 {
320 return true;
321 }
322 let bindings = resolve_import_bindings(self.py, self.file);
323 if bindings.contains_key(name) {
324 return true;
325 }
326 false
327 }
328
329 fn push_field_if_present<'tree>(
330 &self,
331 stack: &mut Vec<ScanFrame<'tree>>,
332 node: Node<'tree>,
333 field_name: &str,
334 ) {
335 if let Some(child) = node.child_by_field_name(field_name) {
336 stack.push(ScanFrame::Node(child));
337 }
338 }
339}
340
341fn file_has_dynamic_unknowns(
342 py: &dyn PythonSource,
343 file: &ProjectFile,
344 source: &str,
345 root: Node<'_>,
346) -> bool {
347 has_unresolved_wildcard_import(py, file)
348 || has_module_getattr(source, root)
349 || has_dynamic_namespace_call(source, root)
350}
351
352fn has_unresolved_wildcard_import(py: &dyn PythonSource, file: &ProjectFile) -> bool {
353 py.import_info_of(file)
354 .iter()
355 .filter(|import| import.is_wildcard)
356 .any(|import| resolve_import(py, file, import).is_empty())
357}
358
359fn has_module_getattr(source: &str, root: Node<'_>) -> bool {
360 let mut cursor = root.walk();
361 root.named_children(&mut cursor).any(|child| {
362 child.kind() == "function_definition"
363 && child
364 .child_by_field_name("name")
365 .is_some_and(|name| node_text(name, source) == "__getattr__")
366 })
367}
368
369fn has_dynamic_namespace_call(source: &str, root: Node<'_>) -> bool {
370 subtree_contains(root, |node| {
371 node.kind() == "call"
372 && node
373 .child_by_field_name("function")
374 .is_some_and(|function| is_dynamic_function(function, source))
375 })
376}
377
378fn is_dynamic_function(node: Node<'_>, source: &str) -> bool {
379 match node.kind() {
380 "identifier" => matches!(node_text(node, source), "globals" | "locals" | "__import__"),
381 "attribute" => node_text(node, source) == "importlib.import_module",
382 _ => false,
383 }
384}
385
386fn collect_bound_identifiers(node: Node<'_>, source: &str, scopes: &mut ScopeStack) {
387 let mut stack = vec![node];
388 while let Some(current) = stack.pop() {
389 match current.kind() {
390 "identifier" => {
391 let text = node_text(current, source).trim();
392 if !text.is_empty() {
393 scopes.declare(text.to_string());
394 }
395 }
396 "attribute" | "call" => {}
397 _ => {
398 let mut cursor = current.walk();
399 for child in current.named_children(&mut cursor) {
400 stack.push(child);
401 }
402 }
403 }
404 }
405}
406
407fn collect_parameter_names(node: Node<'_>, source: &str, scopes: &mut ScopeStack) {
408 let mut cursor = node.walk();
409 for child in node.named_children(&mut cursor) {
410 if let Some(name) = python_parameter_name(child, source) {
411 scopes.declare(name);
412 }
413 }
414}
415
416fn python_parameter_name(node: Node<'_>, source: &str) -> Option<String> {
417 match node.kind() {
418 "identifier" => Some(node_text(node, source).trim().to_string()),
419 "typed_parameter"
420 | "typed_default_parameter"
421 | "default_parameter"
422 | "list_splat_pattern"
423 | "dictionary_splat_pattern" => node
424 .child_by_field_name("name")
425 .or_else(|| {
426 let mut cursor = node.walk();
427 node.named_children(&mut cursor)
428 .find(|child| child.kind() == "identifier")
429 })
430 .and_then(|name| python_parameter_name(name, source)),
431 _ => None,
432 }
433 .filter(|name| !name.is_empty())
434}
435
436fn collect_alias_children(node: Node<'_>, source: &str, scopes: &mut ScopeStack) {
437 let mut cursor = node.walk();
438 for alias in node.children_by_field_name("alias", &mut cursor) {
439 collect_bound_identifiers(alias, source, scopes);
440 }
441 let mut cursor = node.walk();
442 for item in node.named_children(&mut cursor) {
443 let mut item_cursor = item.walk();
444 for alias in item.children_by_field_name("alias", &mut item_cursor) {
445 collect_bound_identifiers(alias, source, scopes);
446 }
447 }
448}
449
450fn push_named_children<'tree>(stack: &mut Vec<ScanFrame<'tree>>, node: Node<'tree>) {
451 let mut cursor = node.walk();
452 let children: Vec<_> = node.named_children(&mut cursor).collect();
453 for child in children.into_iter().rev() {
454 stack.push(ScanFrame::Node(child));
455 }
456}
457
458fn push_named_children_except<'tree>(
459 stack: &mut Vec<ScanFrame<'tree>>,
460 node: Node<'tree>,
461 excluded: Option<Node<'tree>>,
462) {
463 let mut cursor = node.walk();
464 let children: Vec<_> = node
465 .named_children(&mut cursor)
466 .filter(|child| excluded.is_none_or(|excluded| !same_node(*child, excluded)))
467 .collect();
468 for child in children.into_iter().rev() {
469 stack.push(ScanFrame::Node(child));
470 }
471}
472
473fn is_declaration_identifier(node: Node<'_>) -> bool {
474 let Some(parent) = node.parent() else {
475 return false;
476 };
477 match parent.kind() {
478 "function_definition" | "class_definition" => parent
479 .child_by_field_name("name")
480 .is_some_and(|name| same_node(name, node)),
481 "parameters" | "list_splat_pattern" | "dictionary_splat_pattern" => true,
482 "default_parameter" | "typed_parameter" | "typed_default_parameter" => parent
483 .child_by_field_name("name")
484 .is_some_and(|name| contains_node(name, node)),
485 "assignment" | "augmented_assignment" | "for_statement" | "for_in_clause" => parent
486 .child_by_field_name("left")
487 .is_some_and(|left| contains_node(left, node)),
488 "named_expression" => parent
489 .child_by_field_name("name")
490 .is_some_and(|name| contains_node(name, node)),
491 _ => false,
492 }
493}
494
495fn is_import_identifier(node: Node<'_>) -> bool {
496 let mut current = node;
497 while let Some(parent) = current.parent() {
498 if matches!(parent.kind(), "import_statement" | "import_from_statement") {
499 return true;
500 }
501 current = parent;
502 }
503 false
504}
505
506fn is_attribute_identifier(node: Node<'_>) -> bool {
507 let Some(parent) = node.parent() else {
508 return false;
509 };
510 parent.kind() == "attribute"
511 && parent
512 .child_by_field_name("attribute")
513 .is_some_and(|attribute| same_node(attribute, node))
514}
515
516fn is_pattern_identifier(node: Node<'_>) -> bool {
517 let mut current = node;
518 while let Some(parent) = current.parent() {
519 if parent.kind().contains("pattern") {
520 return true;
521 }
522 current = parent;
523 }
524 false
525}
526
527fn is_python_builtin_or_constant(name: &str) -> bool {
528 matches!(
529 name,
530 "None"
531 | "True"
532 | "False"
533 | "NotImplemented"
534 | "Ellipsis"
535 | "__annotations__"
536 | "__builtins__"
537 | "__debug__"
538 | "__doc__"
539 | "__file__"
540 | "__loader__"
541 | "__name__"
542 | "__package__"
543 | "__spec__"
544 | "ArithmeticError"
545 | "AssertionError"
546 | "AttributeError"
547 | "BaseException"
548 | "BaseExceptionGroup"
549 | "BlockingIOError"
550 | "BrokenPipeError"
551 | "BufferError"
552 | "BytesWarning"
553 | "ChildProcessError"
554 | "ConnectionAbortedError"
555 | "ConnectionError"
556 | "ConnectionRefusedError"
557 | "ConnectionResetError"
558 | "DeprecationWarning"
559 | "EOFError"
560 | "EncodingWarning"
561 | "EnvironmentError"
562 | "Exception"
563 | "ExceptionGroup"
564 | "FileExistsError"
565 | "FileNotFoundError"
566 | "FloatingPointError"
567 | "FutureWarning"
568 | "GeneratorExit"
569 | "IOError"
570 | "ImportError"
571 | "ImportWarning"
572 | "IndentationError"
573 | "IndexError"
574 | "InterruptedError"
575 | "IsADirectoryError"
576 | "KeyError"
577 | "KeyboardInterrupt"
578 | "LookupError"
579 | "MemoryError"
580 | "ModuleNotFoundError"
581 | "NameError"
582 | "NotADirectoryError"
583 | "NotImplementedError"
584 | "OSError"
585 | "OverflowError"
586 | "PendingDeprecationWarning"
587 | "PermissionError"
588 | "ProcessLookupError"
589 | "RecursionError"
590 | "ReferenceError"
591 | "ResourceWarning"
592 | "RuntimeError"
593 | "RuntimeWarning"
594 | "StopAsyncIteration"
595 | "StopIteration"
596 | "SyntaxError"
597 | "SyntaxWarning"
598 | "SystemError"
599 | "SystemExit"
600 | "TabError"
601 | "TimeoutError"
602 | "TypeError"
603 | "UnboundLocalError"
604 | "UnicodeDecodeError"
605 | "UnicodeEncodeError"
606 | "UnicodeError"
607 | "UnicodeTranslateError"
608 | "UnicodeWarning"
609 | "UserWarning"
610 | "ValueError"
611 | "Warning"
612 | "ZeroDivisionError"
613 | "abs"
614 | "aiter"
615 | "all"
616 | "anext"
617 | "any"
618 | "ascii"
619 | "bin"
620 | "bool"
621 | "breakpoint"
622 | "bytearray"
623 | "bytes"
624 | "callable"
625 | "chr"
626 | "classmethod"
627 | "compile"
628 | "complex"
629 | "copyright"
630 | "credits"
631 | "delattr"
632 | "dict"
633 | "dir"
634 | "divmod"
635 | "enumerate"
636 | "eval"
637 | "exec"
638 | "exit"
639 | "filter"
640 | "float"
641 | "format"
642 | "frozenset"
643 | "getattr"
644 | "hasattr"
645 | "hash"
646 | "help"
647 | "hex"
648 | "id"
649 | "input"
650 | "int"
651 | "isinstance"
652 | "issubclass"
653 | "iter"
654 | "len"
655 | "license"
656 | "list"
657 | "locals"
658 | "map"
659 | "max"
660 | "memoryview"
661 | "min"
662 | "next"
663 | "object"
664 | "oct"
665 | "open"
666 | "ord"
667 | "pow"
668 | "print"
669 | "property"
670 | "quit"
671 | "range"
672 | "repr"
673 | "reversed"
674 | "round"
675 | "set"
676 | "setattr"
677 | "slice"
678 | "sorted"
679 | "staticmethod"
680 | "str"
681 | "sum"
682 | "super"
683 | "tuple"
684 | "type"
685 | "vars"
686 | "zip"
687 | "__import__"
688 )
689}