Skip to main content

brokk_bifrost_python/
diagnostics.rs

1//! Python's semantic diagnostics: proof-gated unresolved-name reporting.
2//!
3//! Every candidate this pass reaches leaves one typed outcome in the
4//! [`SemanticDiagnosticReport`]: a resolution, a complete absence proof, or a
5//! typed reason why absence could not be proven. A candidate is never dropped
6//! in silence, and an error is only ever published behind a
7//! [`SemanticAbsenceProof`] over a surface that was complete.
8//!
9//! The analyzer facts this needs are the [`PythonSource`] the import resolver
10//! already takes, a [`BoundedDefinitionLookup`] for "is this fqn indexed", and
11//! a [`PythonEnvironmentSurface`] for "what do the activated environment packs
12//! prove about this imported module". `analyzer/python/diagnostics.rs` in
13//! `brokk-bifrost-analysis` keeps the downcast that produces all three: this
14//! crate cannot name the semantic-model overlay those answers come from.
15//!
16//! What this pass judges:
17//!
18//! - every bare-name reference, against the file's lexical surface and the
19//!   workspace index, which are both complete and workspace-local;
20//! - every import declaration, against the retained environment surface;
21//! - an attribute read through a module binder (`os.path.join`), which is the
22//!   one receiver whose owner Python's syntax proves without type inference.
23//!
24//! An attribute on any other receiver is not a candidate here: proving that
25//! `value.method` is absent needs a proven receiver type, which this pass does
26//! not have.
27
28use 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::semantic_diagnostics::{
35    ScopeStack, contains_node, node_range, node_text, same_node,
36};
37use brokk_bifrost_core::analyzer::structural::facts::Span;
38use brokk_bifrost_core::analyzer::structural::resolution::BoundaryStatus;
39use brokk_bifrost_core::analyzer::tree_walk::{
40    WalkControl, collect_parse_errors, walk_tree_preorder,
41};
42use brokk_bifrost_core::analyzer::{BoundedDefinitionLookup, CodeUnit, ProjectFile, Range};
43use brokk_bifrost_core::hash::HashMap;
44use brokk_bifrost_core::text_utils::{compute_line_starts, find_line_index_for_offset};
45use tree_sitter::{Node, Parser};
46
47pub const PYTHON_UNRECOGNIZED_SYMBOL: &str = "python_unrecognized_symbol";
48pub const PYTHON_SEMANTIC_DIAGNOSTIC_SOURCE: &str = "bifrost-python";
49const MAX_PYTHON_SEMANTIC_DIAGNOSTIC_BYTES: usize = 512 * 1024;
50pub const MAX_PYTHON_SEMANTIC_DIAGNOSTICS: usize = 200;
51
52/// What the analyzer's retained environment evidence proves about one name a
53/// Python file reaches across an import boundary.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum PythonEnvironmentBoundary {
56    /// An activated pack surface contains the name.
57    Indexed,
58    /// A complete activated module surface does not contain the name. The
59    /// absence is therefore proven at [`BoundaryStatus::ExternalIndexed`].
60    Absent,
61    /// Retained state cannot decide, for this typed reason.
62    Incomplete(SemanticDiagnosticIncompleteReason),
63}
64
65/// The retained Python environment surface a diagnostic request may read.
66///
67/// Every method answers only from state the analyzer already holds. An
68/// implementation must never start dependency discovery, read a distribution,
69/// or execute Python: a missing answer is
70/// [`PythonEnvironmentBoundary::Incomplete`], never a blocking call.
71pub trait PythonEnvironmentSurface {
72    /// Classify the module a namespace import names (`import a.b`).
73    fn module_boundary(&self, module_path: &str) -> PythonEnvironmentBoundary;
74
75    /// Classify `member` on the environment declaration `owner_path` names.
76    /// This is one question asked from two places: `from a.b import member`,
77    /// and an attribute read through a module binder (`a.b.member`).
78    ///
79    /// The owner is a module for an import, and either a module or a type for
80    /// an attribute read: `theta.Klass.method` reaches here with the owner
81    /// `theta.Klass`. An implementation that answers
82    /// [`PythonEnvironmentBoundary::Absent`] for a type owner is claiming to
83    /// have seen that type's whole inherited surface.
84    fn attribute_boundary(&self, owner_path: &str, member: &str) -> PythonEnvironmentBoundary;
85}
86
87/// An environment that has acquired nothing. Every boundary is unknown, which
88/// is the honest answer for an analyzer no host has activated packs on.
89#[derive(Debug, Clone, Copy, Default)]
90pub struct UnacquiredPythonEnvironment;
91
92impl PythonEnvironmentSurface for UnacquiredPythonEnvironment {
93    fn module_boundary(&self, _module_path: &str) -> PythonEnvironmentBoundary {
94        unknown_boundary()
95    }
96
97    fn attribute_boundary(&self, _owner_path: &str, _member: &str) -> PythonEnvironmentBoundary {
98        unknown_boundary()
99    }
100}
101
102fn unknown_boundary() -> PythonEnvironmentBoundary {
103    PythonEnvironmentBoundary::Incomplete(
104        SemanticDiagnosticIncompleteReason::MissingDependencyDiscovery {
105            boundary: BoundaryStatus::ExternalUnknown,
106        },
107    )
108}
109
110/// Collect Python semantic diagnostics and the proof behind each one.
111pub fn collect_python_semantic_diagnostics(
112    py: &dyn PythonSource,
113    support: &dyn BoundedDefinitionLookup,
114    environment: &dyn PythonEnvironmentSurface,
115    file: &ProjectFile,
116    source: &str,
117) -> SemanticDiagnosticReport {
118    let mut report = SemanticDiagnosticReport::new();
119    if source.len() > MAX_PYTHON_SEMANTIC_DIAGNOSTIC_BYTES {
120        report.push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
121        return report;
122    }
123    let mut parser = Parser::new();
124    if parser
125        .set_language(&tree_sitter_python::LANGUAGE.into())
126        .is_err()
127    {
128        report.push_incomplete(
129            None,
130            vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
131                detail: "Python parser is unavailable".to_string(),
132            }],
133        );
134        return report;
135    }
136    let Some(tree) = parser.parse(source, None) else {
137        report.push_incomplete(
138            None,
139            vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
140                detail: "Python source did not parse".to_string(),
141            }],
142        );
143        return report;
144    };
145    let mut parse_errors = Vec::new();
146    collect_parse_errors(tree.root_node(), &mut parse_errors);
147    if !parse_errors.is_empty() {
148        // The parse errors themselves reach the host through the analyzer's
149        // parse-diagnostic path. What the semantic report records is that the
150        // tree this pass would have judged is not trustworthy, so no name in
151        // the file was checked at all.
152        report.push_incomplete(
153            None,
154            vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
155                detail: "Python source has parse errors".to_string(),
156            }],
157        );
158        return report;
159    }
160
161    let line_starts = compute_line_starts(source);
162    let imports = py.import_info_of(file);
163    let resolved_imports = resolve_imports_batched(py, file, &imports);
164    let dynamic = dynamic_surface_reasons(&imports, &resolved_imports, source, tree.root_node());
165    if !dynamic.is_empty() {
166        // A dynamic namespace makes every name in the file unjudgeable: the
167        // set of bindings is decided at run time. The file is reported once,
168        // with each reason that made it so.
169        report.push_incomplete(None, dynamic);
170        return report;
171    }
172
173    let mut collector = PythonDiagnosticCollector {
174        py,
175        support,
176        environment,
177        file,
178        source,
179        line_starts: &line_starts,
180        module_name: crate::declarations::python_module_name(file),
181        module_binders: HashMap::default(),
182        report,
183    };
184    collector.classify_imports(&imports, &resolved_imports);
185    collector.scan_tree(tree.root_node(), &imports, &resolved_imports);
186    collector.report
187}
188
189struct PythonDiagnosticCollector<'a> {
190    py: &'a dyn PythonSource,
191    support: &'a dyn BoundedDefinitionLookup,
192    environment: &'a dyn PythonEnvironmentSurface,
193    file: &'a ProjectFile,
194    source: &'a str,
195    line_starts: &'a [usize],
196    module_name: String,
197    /// Local name -> the module path it is bound to by a namespace import.
198    /// This is the one receiver whose owner Python's syntax fixes without type
199    /// inference, so it is the only owner this pass judges attributes against.
200    module_binders: HashMap<String, String>,
201    report: SemanticDiagnosticReport,
202}
203
204enum ScanFrame<'tree> {
205    Node(Node<'tree>),
206    ExitScope,
207    SeedTargets(Node<'tree>),
208}
209
210impl PythonDiagnosticCollector<'_> {
211    /// Ask the environment about every import the file declares, once per
212    /// declaration. Later references to an imported name resolve in the file's
213    /// own lexical scope; the boundary question belongs to the import that
214    /// created the binding, which is also where a host can act on the answer.
215    fn classify_imports(&mut self, imports: &[ImportInfo], resolved: &[Vec<(String, CodeUnit)>]) {
216        debug_assert_eq!(imports.len(), resolved.len());
217        for (import, resolved) in imports.iter().zip(resolved) {
218            let range = import
219                .binder_span
220                .map(|span| span_range(span, self.line_starts));
221            let Some(path) = import.path.as_ref() else {
222                self.report.push_incomplete(
223                    range,
224                    vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
225                        detail: format!(
226                            "import `{}` records no parser-derived path",
227                            import.raw_snippet
228                        ),
229                    }],
230                );
231                continue;
232            };
233            let namespace_import = path.kind != Some(StructuredImportPathKind::ImportFrom);
234            if namespace_import && let Some(name) = import.local_name() {
235                // `import a.b` binds `a` and `import a.b as ab` binds `a.b`:
236                // the split `python_namespace_binding_module` makes for import
237                // resolution, over the same parser-derived segments.
238                let bound = if import.alias.is_some() {
239                    path.render_segments(".")
240                } else {
241                    path.segments.first().cloned().unwrap_or_default()
242                };
243                if !bound.is_empty() {
244                    self.module_binders.insert(name.to_string(), bound);
245                }
246            }
247            if !resolved.is_empty() {
248                // The import resolved inside the workspace, whose file set is
249                // complete. A wildcard resolves to every public declaration of
250                // the target module, so the seeded scope stays complete too.
251                if let Some(range) = range {
252                    self.report
253                        .push_resolved(range, BoundaryStatus::WorkspaceLocal);
254                }
255                continue;
256            }
257            let (owner, boundary) = if namespace_import {
258                let module = path.render_segments(".");
259                let boundary = self.environment.module_boundary(&module);
260                (SemanticDiagnosticDomain::Module { name: module }, boundary)
261            } else if let Some((member, module)) = path.segments.split_last() {
262                // A `from a.b import c` path records the module segments and
263                // the imported name in one list.
264                let module = module.join(".");
265                let boundary = self.environment.attribute_boundary(&module, member);
266                (SemanticDiagnosticDomain::Module { name: module }, boundary)
267            } else {
268                self.report.push_incomplete(
269                    range,
270                    vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
271                        detail: format!("import `{}` records no path segments", import.raw_snippet),
272                    }],
273                );
274                continue;
275            };
276            let Some(range) = range else {
277                // Nothing points at one name: a wildcard, or a form whose
278                // bound name is spelled only inside a compound token.
279                self.report.push_incomplete(
280                    None,
281                    vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
282                        detail: format!(
283                            "import `{}` binds no single name token",
284                            import.raw_snippet
285                        ),
286                    }],
287                );
288                continue;
289            };
290            self.record_boundary(range, owner, import.raw_snippet.as_str(), boundary);
291        }
292    }
293
294    /// Place one environment verdict in the report at `range`.
295    fn record_boundary(
296        &mut self,
297        range: Range,
298        domain: SemanticDiagnosticDomain,
299        subject: &str,
300        boundary: PythonEnvironmentBoundary,
301    ) {
302        match boundary {
303            PythonEnvironmentBoundary::Indexed => self
304                .report
305                .push_resolved(range, BoundaryStatus::ExternalIndexed),
306            PythonEnvironmentBoundary::Absent => {
307                let message = match &domain {
308                    // The owner is a module for an import and either a module
309                    // or an indexed type for an attribute read, so the message
310                    // names it without claiming which.
311                    SemanticDiagnosticDomain::MemberSurface { owner, member } => {
312                        format!("Unrecognized Python attribute `{member}` on `{owner}`")
313                    }
314                    _ => format!("Unrecognized Python import `{subject}`"),
315                };
316                self.report.push_absent(
317                    SemanticAbsenceProof {
318                        range,
319                        domain,
320                        boundary: BoundaryStatus::ExternalIndexed,
321                    },
322                    SemanticDiagnostic {
323                        range,
324                        source: PYTHON_SEMANTIC_DIAGNOSTIC_SOURCE,
325                        kind: PYTHON_UNRECOGNIZED_SYMBOL,
326                        message,
327                    },
328                );
329            }
330            PythonEnvironmentBoundary::Incomplete(reason) => {
331                self.report.push_incomplete(Some(range), vec![reason])
332            }
333        }
334    }
335
336    fn scan_tree(
337        &mut self,
338        root: Node<'_>,
339        imports: &[ImportInfo],
340        resolved: &[Vec<(String, CodeUnit)>],
341    ) {
342        let mut scopes = ScopeStack::default();
343        scopes.enter();
344        self.seed_module_scope(&mut scopes, imports, resolved);
345        let mut stack = vec![ScanFrame::Node(root)];
346        while let Some(frame) = stack.pop() {
347            if self.report.diagnostics().len() >= MAX_PYTHON_SEMANTIC_DIAGNOSTICS {
348                self.report
349                    .push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
350                return;
351            }
352            match frame {
353                ScanFrame::Node(node) => self.scan_node(node, &mut scopes, &mut stack),
354                ScanFrame::ExitScope => scopes.exit(),
355                ScanFrame::SeedTargets(node) => self.seed_assignment_targets(node, &mut scopes),
356            }
357        }
358    }
359
360    fn scan_node<'tree>(
361        &mut self,
362        node: Node<'tree>,
363        scopes: &mut ScopeStack,
364        stack: &mut Vec<ScanFrame<'tree>>,
365    ) {
366        match node.kind() {
367            "module" => push_named_children(stack, node),
368            "function_definition" | "lambda" => {
369                self.seed_named_declaration(node, scopes);
370                scopes.enter();
371                self.seed_parameters(node, scopes);
372                stack.push(ScanFrame::ExitScope);
373                push_named_children_except(stack, node, node.child_by_field_name("name"));
374            }
375            "class_definition" => {
376                self.seed_named_declaration(node, scopes);
377                self.push_field_if_present(stack, node, "superclasses");
378                scopes.enter();
379                stack.push(ScanFrame::ExitScope);
380                if let Some(body) = node.child_by_field_name("body") {
381                    stack.push(ScanFrame::Node(body));
382                }
383            }
384            "list_comprehension"
385            | "set_comprehension"
386            | "dictionary_comprehension"
387            | "generator_expression" => {
388                scopes.enter();
389                self.seed_comprehension_targets(node, scopes);
390                stack.push(ScanFrame::ExitScope);
391                push_named_children(stack, node);
392            }
393            "match_statement" => {
394                // A capture pattern binds names this pass does not model, so
395                // no name inside the statement can be judged. Say so once,
396                // with the statement's own range, instead of scanning it.
397                self.report.push_incomplete(
398                    Some(node_range(node, self.line_starts)),
399                    vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
400                        detail: "match statement pattern bindings are not modeled".to_string(),
401                    }],
402                );
403            }
404            "import_statement" | "import_from_statement" => {}
405            "assignment" | "augmented_assignment" | "named_expression" => {
406                stack.push(ScanFrame::SeedTargets(node));
407                self.push_field_if_present(stack, node, "right");
408                self.push_field_if_present(stack, node, "value");
409            }
410            "for_statement" | "for_in_clause" => {
411                if let Some(body) = node.child_by_field_name("body") {
412                    stack.push(ScanFrame::Node(body));
413                }
414                stack.push(ScanFrame::SeedTargets(node));
415                self.push_field_if_present(stack, node, "right");
416            }
417            "with_statement" | "with_item" => {
418                stack.push(ScanFrame::SeedTargets(node));
419                push_named_children(stack, node);
420            }
421            "except_clause" => {
422                self.seed_except_alias(node, scopes);
423                push_named_children(stack, node);
424            }
425            "identifier" => self.check_identifier(node, scopes),
426            "attribute" => {
427                self.check_attribute(node);
428                if let Some(object) = node.child_by_field_name("object") {
429                    stack.push(ScanFrame::Node(object));
430                }
431            }
432            "string" | "string_content" | "comment" => {}
433            _ => push_named_children(stack, node),
434        }
435    }
436
437    fn seed_module_scope(
438        &self,
439        scopes: &mut ScopeStack,
440        imports: &[ImportInfo],
441        resolved: &[Vec<(String, CodeUnit)>],
442    ) {
443        for import in imports {
444            if let Some(local_name) = import.alias.as_ref().or(import.identifier.as_ref()) {
445                scopes.declare(local_name.clone());
446            }
447        }
448        for binding in resolved.iter().flatten() {
449            scopes.declare(binding.0.clone());
450        }
451        for unit in self.py.declarations(self.file) {
452            if !unit.identifier().is_empty() {
453                scopes.declare(unit.identifier().to_string());
454            }
455        }
456    }
457
458    fn seed_named_declaration(&self, node: Node<'_>, scopes: &mut ScopeStack) {
459        if let Some(name) = node.child_by_field_name("name") {
460            let text = node_text(name, self.source).trim();
461            if !text.is_empty() {
462                scopes.declare(text.to_string());
463            }
464        }
465    }
466
467    fn seed_parameters(&self, node: Node<'_>, scopes: &mut ScopeStack) {
468        if let Some(parameters) = node.child_by_field_name("parameters") {
469            collect_parameter_names(parameters, self.source, scopes);
470        }
471    }
472
473    fn seed_assignment_targets(&self, node: Node<'_>, scopes: &mut ScopeStack) {
474        for field in ["left", "name", "alias"] {
475            if let Some(target) = node.child_by_field_name(field) {
476                collect_bound_identifiers(target, self.source, scopes);
477            }
478        }
479        if node.kind() == "with_item" || node.kind() == "with_statement" {
480            collect_alias_children(node, self.source, scopes);
481        }
482    }
483
484    fn seed_except_alias(&self, node: Node<'_>, scopes: &mut ScopeStack) {
485        if let Some(alias) = node.child_by_field_name("alias") {
486            collect_bound_identifiers(alias, self.source, scopes);
487            return;
488        }
489        let mut identifiers = Vec::new();
490        let mut stack = vec![node];
491        while let Some(current) = stack.pop() {
492            if current.kind() == "identifier" {
493                let text = node_text(current, self.source).trim();
494                if !text.is_empty() {
495                    identifiers.push(text.to_string());
496                }
497                continue;
498            }
499            let mut cursor = current.walk();
500            for child in current.named_children(&mut cursor) {
501                stack.push(child);
502            }
503        }
504        if identifiers.len() >= 2
505            && let Some(alias) = identifiers.into_iter().next()
506        {
507            scopes.declare(alias);
508        }
509    }
510
511    fn seed_comprehension_targets(&self, node: Node<'_>, scopes: &mut ScopeStack) {
512        let mut stack = vec![node];
513        while let Some(current) = stack.pop() {
514            if matches!(current.kind(), "for_statement" | "for_in_clause")
515                && let Some(left) = current.child_by_field_name("left")
516            {
517                collect_bound_identifiers(left, self.source, scopes);
518            }
519            let mut cursor = current.walk();
520            for child in current.named_children(&mut cursor) {
521                stack.push(child);
522            }
523        }
524    }
525
526    fn check_identifier(&mut self, node: Node<'_>, scopes: &ScopeStack) {
527        if !self.is_reference_identifier(node) {
528            return;
529        }
530        let name = node_text(node, self.source);
531        // `_` is the conventional throwaway target; a read of it names no
532        // declaration this pass can check either way.
533        if name.is_empty() || name == "_" {
534            return;
535        }
536        let range = node_range(node, self.line_starts);
537        if is_python_builtin_or_constant(name) {
538            // The builtin surface is a complete table compiled into this
539            // analyzer, so it is an indexed external surface.
540            self.report
541                .push_resolved(range, BoundaryStatus::ExternalIndexed);
542            return;
543        }
544        if scopes.contains(name) || self.name_resolves_project_locally(name) {
545            self.report
546                .push_resolved(range, BoundaryStatus::WorkspaceLocal);
547            return;
548        }
549        // The file's lexical surface and the workspace index are both complete
550        // and workspace-local, and every import in the file seeded a binding
551        // into that surface, so this name is absent from it.
552        self.report.push_absent(
553            SemanticAbsenceProof {
554                range,
555                domain: SemanticDiagnosticDomain::LexicalScope {
556                    file: self.file.rel_path().to_path_buf(),
557                    range,
558                },
559                boundary: BoundaryStatus::WorkspaceLocal,
560            },
561            SemanticDiagnostic {
562                range,
563                source: PYTHON_SEMANTIC_DIAGNOSTIC_SOURCE,
564                kind: PYTHON_UNRECOGNIZED_SYMBOL,
565                message: format!("Unrecognized Python symbol `{name}`"),
566            },
567        );
568    }
569
570    /// Judge an attribute whose receiver chain starts at a module binder.
571    fn check_attribute(&mut self, node: Node<'_>) {
572        let Some((owner, member)) = self.module_attribute_target(node) else {
573            return;
574        };
575        let Some(attribute) = node.child_by_field_name("attribute") else {
576            return;
577        };
578        let range = node_range(attribute, self.line_starts);
579        let boundary = self.environment.attribute_boundary(&owner, &member);
580        self.record_boundary(
581            range,
582            SemanticDiagnosticDomain::MemberSurface {
583                owner,
584                member: member.clone(),
585            },
586            &member,
587            boundary,
588        );
589    }
590
591    /// The owning module path and member name of an attribute read whose
592    /// receiver is a chain of plain names rooted at a module binder, e.g.
593    /// `os.path.join` -> (`os.path`, `join`). `None` when the receiver is
594    /// anything else, because then this pass cannot prove what owns the
595    /// member.
596    ///
597    /// A local rebinding of an imported module name would defeat this, which
598    /// no working program does: rebinding `os` to a non-module makes every
599    /// later `os.*` read fail at run time.
600    fn module_attribute_target(&self, node: Node<'_>) -> Option<(String, String)> {
601        let member = node_text(node.child_by_field_name("attribute")?, self.source);
602        if member.is_empty() {
603            return None;
604        }
605        let mut intermediate = Vec::new();
606        let mut current = node.child_by_field_name("object")?;
607        while current.kind() == "attribute" {
608            intermediate.push(node_text(
609                current.child_by_field_name("attribute")?,
610                self.source,
611            ));
612            current = current.child_by_field_name("object")?;
613        }
614        if current.kind() != "identifier" {
615            return None;
616        }
617        let mut owner = self
618            .module_binders
619            .get(node_text(current, self.source))?
620            .clone();
621        for segment in intermediate.iter().rev() {
622            owner.push('.');
623            owner.push_str(segment);
624        }
625        Some((owner, member.to_string()))
626    }
627
628    fn is_reference_identifier(&self, node: Node<'_>) -> bool {
629        if is_declaration_identifier(node)
630            || is_import_identifier(node)
631            || is_attribute_identifier(node)
632            || is_pattern_identifier(node)
633        {
634            return false;
635        }
636        let mut current = node;
637        while let Some(parent) = current.parent() {
638            if matches!(parent.kind(), "string" | "string_content" | "comment") {
639                return false;
640            }
641            current = parent;
642        }
643        true
644    }
645
646    fn name_resolves_project_locally(&self, name: &str) -> bool {
647        if !self.support.file_identifier(self.file, name).is_empty() {
648            return true;
649        }
650        if !self
651            .support
652            .fqn(&format!("{}.{}", self.module_name, name))
653            .is_empty()
654        {
655            return true;
656        }
657        false
658    }
659
660    fn push_field_if_present<'tree>(
661        &self,
662        stack: &mut Vec<ScanFrame<'tree>>,
663        node: Node<'tree>,
664        field_name: &str,
665    ) {
666        if let Some(child) = node.child_by_field_name(field_name) {
667            stack.push(ScanFrame::Node(child));
668        }
669    }
670}
671
672fn span_range(span: Span, line_starts: &[usize]) -> Range {
673    Range {
674        start_byte: span.start_byte,
675        end_byte: span.end_byte,
676        start_line: find_line_index_for_offset(line_starts, span.start_byte) + 1,
677        end_line: find_line_index_for_offset(line_starts, span.end_byte.saturating_sub(1)) + 1,
678    }
679}
680
681/// Every dynamic-namespace feature the file uses, each as the typed reason it
682/// makes the file's binding set unknowable. An empty result means the file's
683/// namespace is static and every name in it can be judged.
684fn dynamic_surface_reasons(
685    imports: &[ImportInfo],
686    resolved: &[Vec<(String, CodeUnit)>],
687    source: &str,
688    root: Node<'_>,
689) -> Vec<SemanticDiagnosticIncompleteReason> {
690    debug_assert_eq!(imports.len(), resolved.len());
691    let mut reasons = Vec::new();
692    for (import, resolved) in imports.iter().zip(resolved) {
693        if import.is_wildcard && resolved.is_empty() {
694            reasons.push(SemanticDiagnosticIncompleteReason::DynamicBehavior {
695                detail: format!(
696                    "`{}` binds an unknown set of names",
697                    import.raw_snippet.trim()
698                ),
699            });
700        }
701    }
702    if has_module_getattr(source, root) {
703        reasons.push(SemanticDiagnosticIncompleteReason::DynamicBehavior {
704            detail: "the module defines `__getattr__`".to_string(),
705        });
706    }
707    for call in dynamic_namespace_calls(source, root) {
708        reasons.push(SemanticDiagnosticIncompleteReason::DynamicBehavior {
709            detail: format!("the module calls `{call}`"),
710        });
711    }
712    reasons
713}
714
715fn has_module_getattr(source: &str, root: Node<'_>) -> bool {
716    let mut cursor = root.walk();
717    root.named_children(&mut cursor).any(|child| {
718        child.kind() == "function_definition"
719            && child
720                .child_by_field_name("name")
721                .is_some_and(|name| node_text(name, source) == "__getattr__")
722    })
723}
724
725/// The distinct namespace-mutating calls the file makes, in first-seen order.
726fn dynamic_namespace_calls(source: &str, root: Node<'_>) -> Vec<String> {
727    let mut calls: Vec<String> = Vec::new();
728    walk_tree_preorder(root, true, |node| {
729        if node.kind() == "call"
730            && let Some(function) = node.child_by_field_name("function")
731            && let Some(name) = dynamic_function_name(function, source)
732            && !calls.iter().any(|seen| seen == name)
733        {
734            calls.push(name.to_string());
735        }
736        WalkControl::Continue
737    });
738    calls
739}
740
741fn dynamic_function_name<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> {
742    let text = node_text(node, source);
743    match node.kind() {
744        "identifier" => matches!(text, "globals" | "locals" | "__import__").then_some(text),
745        "attribute" => (text == "importlib.import_module").then_some(text),
746        _ => None,
747    }
748}
749
750fn collect_bound_identifiers(node: Node<'_>, source: &str, scopes: &mut ScopeStack) {
751    let mut stack = vec![node];
752    while let Some(current) = stack.pop() {
753        match current.kind() {
754            "identifier" => {
755                let text = node_text(current, source).trim();
756                if !text.is_empty() {
757                    scopes.declare(text.to_string());
758                }
759            }
760            "attribute" | "call" => {}
761            _ => {
762                let mut cursor = current.walk();
763                for child in current.named_children(&mut cursor) {
764                    stack.push(child);
765                }
766            }
767        }
768    }
769}
770
771fn collect_parameter_names(node: Node<'_>, source: &str, scopes: &mut ScopeStack) {
772    let mut cursor = node.walk();
773    for child in node.named_children(&mut cursor) {
774        if let Some(name) = python_parameter_name(child, source) {
775            scopes.declare(name);
776        }
777    }
778}
779
780fn python_parameter_name(node: Node<'_>, source: &str) -> Option<String> {
781    match node.kind() {
782        "identifier" => Some(node_text(node, source).trim().to_string()),
783        "typed_parameter"
784        | "typed_default_parameter"
785        | "default_parameter"
786        | "list_splat_pattern"
787        | "dictionary_splat_pattern" => node
788            .child_by_field_name("name")
789            .or_else(|| {
790                let mut cursor = node.walk();
791                node.named_children(&mut cursor)
792                    .find(|child| child.kind() == "identifier")
793            })
794            .and_then(|name| python_parameter_name(name, source)),
795        _ => None,
796    }
797    .filter(|name| !name.is_empty())
798}
799
800fn collect_alias_children(node: Node<'_>, source: &str, scopes: &mut ScopeStack) {
801    let mut cursor = node.walk();
802    for alias in node.children_by_field_name("alias", &mut cursor) {
803        collect_bound_identifiers(alias, source, scopes);
804    }
805    let mut cursor = node.walk();
806    for item in node.named_children(&mut cursor) {
807        let mut item_cursor = item.walk();
808        for alias in item.children_by_field_name("alias", &mut item_cursor) {
809            collect_bound_identifiers(alias, source, scopes);
810        }
811    }
812}
813
814fn push_named_children<'tree>(stack: &mut Vec<ScanFrame<'tree>>, node: Node<'tree>) {
815    let mut cursor = node.walk();
816    let children: Vec<_> = node.named_children(&mut cursor).collect();
817    for child in children.into_iter().rev() {
818        stack.push(ScanFrame::Node(child));
819    }
820}
821
822fn push_named_children_except<'tree>(
823    stack: &mut Vec<ScanFrame<'tree>>,
824    node: Node<'tree>,
825    excluded: Option<Node<'tree>>,
826) {
827    let mut cursor = node.walk();
828    let children: Vec<_> = node
829        .named_children(&mut cursor)
830        .filter(|child| excluded.is_none_or(|excluded| !same_node(*child, excluded)))
831        .collect();
832    for child in children.into_iter().rev() {
833        stack.push(ScanFrame::Node(child));
834    }
835}
836
837fn is_declaration_identifier(node: Node<'_>) -> bool {
838    let Some(parent) = node.parent() else {
839        return false;
840    };
841    match parent.kind() {
842        "function_definition" | "class_definition" => parent
843            .child_by_field_name("name")
844            .is_some_and(|name| same_node(name, node)),
845        "parameters" | "list_splat_pattern" | "dictionary_splat_pattern" => true,
846        "default_parameter" | "typed_parameter" | "typed_default_parameter" => parent
847            .child_by_field_name("name")
848            .is_some_and(|name| contains_node(name, node)),
849        "assignment" | "augmented_assignment" | "for_statement" | "for_in_clause" => parent
850            .child_by_field_name("left")
851            .is_some_and(|left| contains_node(left, node)),
852        "named_expression" => parent
853            .child_by_field_name("name")
854            .is_some_and(|name| contains_node(name, node)),
855        _ => false,
856    }
857}
858
859fn is_import_identifier(node: Node<'_>) -> bool {
860    let mut current = node;
861    while let Some(parent) = current.parent() {
862        if matches!(parent.kind(), "import_statement" | "import_from_statement") {
863            return true;
864        }
865        current = parent;
866    }
867    false
868}
869
870fn is_attribute_identifier(node: Node<'_>) -> bool {
871    let Some(parent) = node.parent() else {
872        return false;
873    };
874    parent.kind() == "attribute"
875        && parent
876            .child_by_field_name("attribute")
877            .is_some_and(|attribute| same_node(attribute, node))
878}
879
880fn is_pattern_identifier(node: Node<'_>) -> bool {
881    let mut current = node;
882    while let Some(parent) = current.parent() {
883        if parent.kind().contains("pattern") {
884            return true;
885        }
886        current = parent;
887    }
888    false
889}
890
891fn is_python_builtin_or_constant(name: &str) -> bool {
892    matches!(
893        name,
894        "None"
895            | "True"
896            | "False"
897            | "NotImplemented"
898            | "Ellipsis"
899            | "__annotations__"
900            | "__builtins__"
901            | "__debug__"
902            | "__doc__"
903            | "__file__"
904            | "__loader__"
905            | "__name__"
906            | "__package__"
907            | "__spec__"
908            | "ArithmeticError"
909            | "AssertionError"
910            | "AttributeError"
911            | "BaseException"
912            | "BaseExceptionGroup"
913            | "BlockingIOError"
914            | "BrokenPipeError"
915            | "BufferError"
916            | "BytesWarning"
917            | "ChildProcessError"
918            | "ConnectionAbortedError"
919            | "ConnectionError"
920            | "ConnectionRefusedError"
921            | "ConnectionResetError"
922            | "DeprecationWarning"
923            | "EOFError"
924            | "EncodingWarning"
925            | "EnvironmentError"
926            | "Exception"
927            | "ExceptionGroup"
928            | "FileExistsError"
929            | "FileNotFoundError"
930            | "FloatingPointError"
931            | "FutureWarning"
932            | "GeneratorExit"
933            | "IOError"
934            | "ImportError"
935            | "ImportWarning"
936            | "IndentationError"
937            | "IndexError"
938            | "InterruptedError"
939            | "IsADirectoryError"
940            | "KeyError"
941            | "KeyboardInterrupt"
942            | "LookupError"
943            | "MemoryError"
944            | "ModuleNotFoundError"
945            | "NameError"
946            | "NotADirectoryError"
947            | "NotImplementedError"
948            | "OSError"
949            | "OverflowError"
950            | "PendingDeprecationWarning"
951            | "PermissionError"
952            | "ProcessLookupError"
953            | "RecursionError"
954            | "ReferenceError"
955            | "ResourceWarning"
956            | "RuntimeError"
957            | "RuntimeWarning"
958            | "StopAsyncIteration"
959            | "StopIteration"
960            | "SyntaxError"
961            | "SyntaxWarning"
962            | "SystemError"
963            | "SystemExit"
964            | "TabError"
965            | "TimeoutError"
966            | "TypeError"
967            | "UnboundLocalError"
968            | "UnicodeDecodeError"
969            | "UnicodeEncodeError"
970            | "UnicodeError"
971            | "UnicodeTranslateError"
972            | "UnicodeWarning"
973            | "UserWarning"
974            | "ValueError"
975            | "Warning"
976            | "ZeroDivisionError"
977            | "abs"
978            | "aiter"
979            | "all"
980            | "anext"
981            | "any"
982            | "ascii"
983            | "bin"
984            | "bool"
985            | "breakpoint"
986            | "bytearray"
987            | "bytes"
988            | "callable"
989            | "chr"
990            | "classmethod"
991            | "compile"
992            | "complex"
993            | "copyright"
994            | "credits"
995            | "delattr"
996            | "dict"
997            | "dir"
998            | "divmod"
999            | "enumerate"
1000            | "eval"
1001            | "exec"
1002            | "exit"
1003            | "filter"
1004            | "float"
1005            | "format"
1006            | "frozenset"
1007            | "getattr"
1008            | "hasattr"
1009            | "hash"
1010            | "help"
1011            | "hex"
1012            | "id"
1013            | "input"
1014            | "int"
1015            | "isinstance"
1016            | "issubclass"
1017            | "iter"
1018            | "len"
1019            | "license"
1020            | "list"
1021            | "locals"
1022            | "map"
1023            | "max"
1024            | "memoryview"
1025            | "min"
1026            | "next"
1027            | "object"
1028            | "oct"
1029            | "open"
1030            | "ord"
1031            | "pow"
1032            | "print"
1033            | "property"
1034            | "quit"
1035            | "range"
1036            | "repr"
1037            | "reversed"
1038            | "round"
1039            | "set"
1040            | "setattr"
1041            | "slice"
1042            | "sorted"
1043            | "staticmethod"
1044            | "str"
1045            | "sum"
1046            | "super"
1047            | "tuple"
1048            | "type"
1049            | "vars"
1050            | "zip"
1051            | "__import__"
1052    )
1053}