Skip to main content

brokk_bifrost_python/
diagnostics.rs

1//! Python's semantic diagnostics: conservative unresolved-name reporting.
2//!
3//! The analyzer facts this needs are the [`PythonSource`] the import
4//! resolver already takes, plus a [`BoundedDefinitionLookup`] for "is this fqn
5//! indexed". `analyzer/python/diagnostics.rs` in `brokk-bifrost-analysis` keeps
6//! the downcast that produces them.
7
8use 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
42/// Collect conservative Python unresolved-name diagnostics.
43///
44/// This pass intentionally emits only for bare-name references. It suppresses
45/// files that use dynamic namespace features or unresolved wildcard imports,
46/// and it never diagnoses attribute/member names. Python programs commonly use
47/// optional imports, `sys.path` mutation, module `__getattr__`, monkey-patched
48/// attributes, and dynamic imports; those cases make absence unknowable without
49/// a fuller Python runtime model.
50pub 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}