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