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, push_named_children_reversed_where_as, walk_tree_preorder,
42};
43use brokk_bifrost_core::analyzer::{BoundedDefinitionLookup, CodeUnit, ProjectFile, Range};
44use brokk_bifrost_core::hash::HashMap;
45use brokk_bifrost_core::text_utils::compute_line_starts;
46use tree_sitter::{Node, Parser};
47
48pub const PYTHON_UNRECOGNIZED_SYMBOL: &str = "python_unrecognized_symbol";
49pub const PYTHON_SEMANTIC_DIAGNOSTIC_SOURCE: &str = "bifrost-python";
50const MAX_PYTHON_SEMANTIC_DIAGNOSTIC_BYTES: usize = 512 * 1024;
51pub const MAX_PYTHON_SEMANTIC_DIAGNOSTICS: usize = 200;
52
53/// 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::missing_dependency_discovery(
106            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(py.project(), 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" => brokk_bifrost_core::analyzer::tree_walk::push_named_children_reversed_as(
370                node,
371                stack,
372                ScanFrame::Node,
373            ),
374            "function_definition" | "lambda" => {
375                self.seed_named_declaration(node, scopes);
376                scopes.enter();
377                self.seed_parameters(node, scopes);
378                stack.push(ScanFrame::ExitScope);
379                push_named_children_except(stack, node, node.child_by_field_name("name"));
380            }
381            "class_definition" => {
382                self.seed_named_declaration(node, scopes);
383                self.push_field_if_present(stack, node, "superclasses");
384                scopes.enter();
385                stack.push(ScanFrame::ExitScope);
386                if let Some(body) = node.child_by_field_name("body") {
387                    stack.push(ScanFrame::Node(body));
388                }
389            }
390            "list_comprehension"
391            | "set_comprehension"
392            | "dictionary_comprehension"
393            | "generator_expression" => {
394                scopes.enter();
395                self.seed_comprehension_targets(node, scopes);
396                stack.push(ScanFrame::ExitScope);
397                brokk_bifrost_core::analyzer::tree_walk::push_named_children_reversed_as(
398                    node,
399                    stack,
400                    ScanFrame::Node,
401                );
402            }
403            "match_statement" => {
404                // A capture pattern binds names this pass does not model, so
405                // no name inside the statement can be judged. Say so once,
406                // with the statement's own range, instead of scanning it.
407                self.report.push_incomplete(
408                    Some(node_range(node, self.line_starts)),
409                    vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
410                        detail: "match statement pattern bindings are not modeled".to_string(),
411                    }],
412                );
413            }
414            "import_statement" | "import_from_statement" => {}
415            "assignment" | "augmented_assignment" | "named_expression" => {
416                stack.push(ScanFrame::SeedTargets(node));
417                self.push_field_if_present(stack, node, "right");
418                self.push_field_if_present(stack, node, "value");
419            }
420            "for_statement" | "for_in_clause" => {
421                if let Some(body) = node.child_by_field_name("body") {
422                    stack.push(ScanFrame::Node(body));
423                }
424                stack.push(ScanFrame::SeedTargets(node));
425                self.push_field_if_present(stack, node, "right");
426            }
427            "with_statement" | "with_item" => {
428                stack.push(ScanFrame::SeedTargets(node));
429                brokk_bifrost_core::analyzer::tree_walk::push_named_children_reversed_as(
430                    node,
431                    stack,
432                    ScanFrame::Node,
433                );
434            }
435            "except_clause" => {
436                self.seed_except_alias(node, scopes);
437                brokk_bifrost_core::analyzer::tree_walk::push_named_children_reversed_as(
438                    node,
439                    stack,
440                    ScanFrame::Node,
441                );
442            }
443            "identifier" => self.check_identifier(node, scopes),
444            "attribute" => {
445                self.check_attribute(node);
446                if let Some(object) = node.child_by_field_name("object") {
447                    stack.push(ScanFrame::Node(object));
448                }
449            }
450            "string" | "string_content" | "comment" => {}
451            _ => brokk_bifrost_core::analyzer::tree_walk::push_named_children_reversed_as(
452                node,
453                stack,
454                ScanFrame::Node,
455            ),
456        }
457    }
458
459    fn seed_module_scope(
460        &self,
461        scopes: &mut ScopeStack,
462        imports: &[ImportInfo],
463        resolved: &[Vec<(String, CodeUnit)>],
464    ) {
465        for import in imports {
466            if let Some(local_name) = import.alias.as_ref().or(import.identifier.as_ref()) {
467                scopes.declare(local_name.clone());
468            }
469        }
470        for binding in resolved.iter().flatten() {
471            scopes.declare(binding.0.clone());
472        }
473        for unit in self.py.declarations(self.file) {
474            if !unit.identifier().is_empty() {
475                scopes.declare(unit.identifier().to_string());
476            }
477        }
478    }
479
480    fn seed_named_declaration(&self, node: Node<'_>, scopes: &mut ScopeStack) {
481        if let Some(name) = node.child_by_field_name("name") {
482            let text = node_text(name, self.source).trim();
483            if !text.is_empty() {
484                scopes.declare(text.to_string());
485            }
486        }
487    }
488
489    fn seed_parameters(&self, node: Node<'_>, scopes: &mut ScopeStack) {
490        if let Some(parameters) = node.child_by_field_name("parameters") {
491            collect_parameter_names(parameters, self.source, scopes);
492        }
493    }
494
495    fn seed_assignment_targets(&self, node: Node<'_>, scopes: &mut ScopeStack) {
496        for field in ["left", "name", "alias"] {
497            if let Some(target) = node.child_by_field_name(field) {
498                collect_bound_identifiers(target, self.source, scopes);
499            }
500        }
501        if node.kind() == "with_item" || node.kind() == "with_statement" {
502            collect_alias_children(node, self.source, scopes);
503        }
504    }
505
506    fn seed_except_alias(&self, node: Node<'_>, scopes: &mut ScopeStack) {
507        if let Some(alias) = node.child_by_field_name("alias") {
508            collect_bound_identifiers(alias, self.source, scopes);
509            return;
510        }
511        let mut identifiers = Vec::new();
512        let mut stack = vec![node];
513        while let Some(current) = stack.pop() {
514            if current.kind() == "identifier" {
515                let text = node_text(current, self.source).trim();
516                if !text.is_empty() {
517                    identifiers.push(text.to_string());
518                }
519                continue;
520            }
521            let mut cursor = current.walk();
522            for child in current.named_children(&mut cursor) {
523                stack.push(child);
524            }
525        }
526        if identifiers.len() >= 2
527            && let Some(alias) = identifiers.into_iter().next()
528        {
529            scopes.declare(alias);
530        }
531    }
532
533    fn seed_comprehension_targets(&self, node: Node<'_>, scopes: &mut ScopeStack) {
534        let mut stack = vec![node];
535        while let Some(current) = stack.pop() {
536            if matches!(current.kind(), "for_statement" | "for_in_clause")
537                && let Some(left) = current.child_by_field_name("left")
538            {
539                collect_bound_identifiers(left, self.source, scopes);
540            }
541            let mut cursor = current.walk();
542            for child in current.named_children(&mut cursor) {
543                stack.push(child);
544            }
545        }
546    }
547
548    fn check_identifier(&mut self, node: Node<'_>, scopes: &ScopeStack) {
549        if !self.is_reference_identifier(node) {
550            return;
551        }
552        let name = node_text(node, self.source);
553        // `_` is the conventional throwaway target; a read of it names no
554        // declaration this pass can check either way.
555        if name.is_empty() || name == "_" {
556            return;
557        }
558        let range = node_range(node, self.line_starts);
559        if is_python_builtin_or_constant(name) {
560            // The builtin surface is a complete table compiled into this
561            // analyzer, so it is an indexed external surface.
562            self.report
563                .push_resolved(range, BoundaryStatus::ExternalIndexed);
564            return;
565        }
566        if scopes.contains(name) || self.name_resolves_project_locally(name) {
567            self.report
568                .push_resolved(range, BoundaryStatus::WorkspaceLocal);
569            return;
570        }
571        // The file's lexical surface and the workspace index are both complete
572        // and workspace-local, and every import in the file seeded a binding
573        // into that surface, so this name is absent from it.
574        self.report.push_absent(
575            SemanticAbsenceProof {
576                range,
577                domain: SemanticDiagnosticDomain::LexicalScope {
578                    file: self.file.rel_path().to_path_buf(),
579                    range,
580                },
581                boundary: BoundaryStatus::WorkspaceLocal,
582            },
583            SemanticDiagnostic {
584                range,
585                source: PYTHON_SEMANTIC_DIAGNOSTIC_SOURCE,
586                kind: PYTHON_UNRECOGNIZED_SYMBOL,
587                message: format!("Unrecognized Python symbol `{name}`"),
588            },
589        );
590    }
591
592    /// Judge an attribute whose receiver chain starts at a module binder.
593    fn check_attribute(&mut self, node: Node<'_>) {
594        let Some((owner, member)) = self.module_attribute_target(node) else {
595            return;
596        };
597        let Some(attribute) = node.child_by_field_name("attribute") else {
598            return;
599        };
600        let range = node_range(attribute, self.line_starts);
601        let boundary = self.environment.attribute_boundary(&owner, &member);
602        self.record_boundary(
603            range,
604            SemanticDiagnosticDomain::MemberSurface {
605                owner,
606                member: member.clone(),
607            },
608            &member,
609            boundary,
610        );
611    }
612
613    /// The owning module path and member name of an attribute read whose
614    /// receiver is a chain of plain names rooted at a module binder, e.g.
615    /// `os.path.join` -> (`os.path`, `join`). `None` when the receiver is
616    /// anything else, because then this pass cannot prove what owns the
617    /// member.
618    ///
619    /// A local rebinding of an imported module name would defeat this, which
620    /// no working program does: rebinding `os` to a non-module makes every
621    /// later `os.*` read fail at run time.
622    fn module_attribute_target(&self, node: Node<'_>) -> Option<(String, String)> {
623        let member = node_text(node.child_by_field_name("attribute")?, self.source);
624        if member.is_empty() {
625            return None;
626        }
627        let mut intermediate = Vec::new();
628        let mut current = node.child_by_field_name("object")?;
629        while current.kind() == "attribute" {
630            intermediate.push(node_text(
631                current.child_by_field_name("attribute")?,
632                self.source,
633            ));
634            current = current.child_by_field_name("object")?;
635        }
636        if current.kind() != "identifier" {
637            return None;
638        }
639        let mut owner = self
640            .module_binders
641            .get(node_text(current, self.source))?
642            .clone();
643        for segment in intermediate.iter().rev() {
644            owner.push('.');
645            owner.push_str(segment);
646        }
647        Some((owner, member.to_string()))
648    }
649
650    fn is_reference_identifier(&self, node: Node<'_>) -> bool {
651        if is_declaration_identifier(node)
652            || is_import_identifier(node)
653            || is_attribute_identifier(node)
654            || is_pattern_identifier(node)
655        {
656            return false;
657        }
658        let mut current = node;
659        while let Some(parent) = current.parent() {
660            if matches!(parent.kind(), "string" | "string_content" | "comment") {
661                return false;
662            }
663            current = parent;
664        }
665        true
666    }
667
668    fn name_resolves_project_locally(&self, name: &str) -> bool {
669        if !self.support.file_identifier(self.file, name).is_empty() {
670            return true;
671        }
672        if !self
673            .support
674            .fqn(&format!("{}.{}", self.module_name, name))
675            .is_empty()
676        {
677            return true;
678        }
679        false
680    }
681
682    fn push_field_if_present<'tree>(
683        &self,
684        stack: &mut Vec<ScanFrame<'tree>>,
685        node: Node<'tree>,
686        field_name: &str,
687    ) {
688        if let Some(child) = node.child_by_field_name(field_name) {
689            stack.push(ScanFrame::Node(child));
690        }
691    }
692}
693
694fn span_range(span: Span, line_starts: &[usize]) -> Range {
695    brokk_bifrost_core::analyzer::semantic_diagnostics::range_for_byte_span(
696        line_starts,
697        span.start_byte,
698        span.end_byte,
699    )
700}
701
702/// Every dynamic-namespace feature the file uses, each as the typed reason it
703/// makes the file's binding set unknowable. An empty result means the file's
704/// namespace is static and every name in it can be judged.
705fn dynamic_surface_reasons(
706    imports: &[ImportInfo],
707    resolved: &[Vec<(String, CodeUnit)>],
708    source: &str,
709    root: Node<'_>,
710) -> Vec<SemanticDiagnosticIncompleteReason> {
711    debug_assert_eq!(imports.len(), resolved.len());
712    let mut reasons = Vec::new();
713    for (import, resolved) in imports.iter().zip(resolved) {
714        if import.is_wildcard && resolved.is_empty() {
715            reasons.push(SemanticDiagnosticIncompleteReason::DynamicBehavior {
716                detail: format!(
717                    "`{}` binds an unknown set of names",
718                    import.raw_snippet.trim()
719                ),
720            });
721        }
722    }
723    if has_module_getattr(source, root) {
724        reasons.push(SemanticDiagnosticIncompleteReason::DynamicBehavior {
725            detail: "the module defines `__getattr__`".to_string(),
726        });
727    }
728    for call in dynamic_namespace_calls(source, root) {
729        reasons.push(SemanticDiagnosticIncompleteReason::DynamicBehavior {
730            detail: format!("the module calls `{call}`"),
731        });
732    }
733    reasons
734}
735
736fn has_module_getattr(source: &str, root: Node<'_>) -> bool {
737    let mut cursor = root.walk();
738    root.named_children(&mut cursor).any(|child| {
739        child.kind() == "function_definition"
740            && child
741                .child_by_field_name("name")
742                .is_some_and(|name| node_text(name, source) == "__getattr__")
743    })
744}
745
746/// The distinct namespace-mutating calls the file makes, in first-seen order.
747fn dynamic_namespace_calls(source: &str, root: Node<'_>) -> Vec<String> {
748    let mut calls: Vec<String> = Vec::new();
749    walk_tree_preorder(root, true, |node| {
750        if node.kind() == "call"
751            && let Some(function) = node.child_by_field_name("function")
752            && let Some(name) = dynamic_function_name(function, source)
753            && !calls.iter().any(|seen| seen == name)
754        {
755            calls.push(name.to_string());
756        }
757        WalkControl::Continue
758    });
759    calls
760}
761
762fn dynamic_function_name<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> {
763    let text = node_text(node, source);
764    match node.kind() {
765        "identifier" => matches!(text, "globals" | "locals" | "__import__").then_some(text),
766        "attribute" => (text == "importlib.import_module").then_some(text),
767        _ => None,
768    }
769}
770
771fn collect_bound_identifiers(node: Node<'_>, source: &str, scopes: &mut ScopeStack) {
772    let mut stack = vec![node];
773    while let Some(current) = stack.pop() {
774        match current.kind() {
775            "identifier" => {
776                let text = node_text(current, source).trim();
777                if !text.is_empty() {
778                    scopes.declare(text.to_string());
779                }
780            }
781            "attribute" | "call" => {}
782            _ => {
783                let mut cursor = current.walk();
784                for child in current.named_children(&mut cursor) {
785                    stack.push(child);
786                }
787            }
788        }
789    }
790}
791
792fn collect_parameter_names(node: Node<'_>, source: &str, scopes: &mut ScopeStack) {
793    let mut cursor = node.walk();
794    for child in node.named_children(&mut cursor) {
795        if let Some(name) = python_parameter_name(child, source) {
796            scopes.declare(name);
797        }
798    }
799}
800
801use crate::syntax::python_parameter_name;
802
803fn collect_alias_children(node: Node<'_>, source: &str, scopes: &mut ScopeStack) {
804    let mut cursor = node.walk();
805    for alias in node.children_by_field_name("alias", &mut cursor) {
806        collect_bound_identifiers(alias, source, scopes);
807    }
808    let mut cursor = node.walk();
809    for item in node.named_children(&mut cursor) {
810        let mut item_cursor = item.walk();
811        for alias in item.children_by_field_name("alias", &mut item_cursor) {
812            collect_bound_identifiers(alias, source, scopes);
813        }
814    }
815}
816
817fn push_named_children_except<'tree>(
818    stack: &mut Vec<ScanFrame<'tree>>,
819    node: Node<'tree>,
820    excluded: Option<Node<'tree>>,
821) {
822    push_named_children_reversed_where_as(
823        node,
824        stack,
825        |child| excluded.is_none_or(|excluded| !same_node(child, excluded)),
826        ScanFrame::Node,
827    );
828}
829
830fn is_declaration_identifier(node: Node<'_>) -> bool {
831    let Some(parent) = node.parent() else {
832        return false;
833    };
834    match parent.kind() {
835        "function_definition" | "class_definition" => parent
836            .child_by_field_name("name")
837            .is_some_and(|name| same_node(name, node)),
838        "parameters" | "list_splat_pattern" | "dictionary_splat_pattern" => true,
839        "default_parameter" | "typed_parameter" | "typed_default_parameter" => parent
840            .child_by_field_name("name")
841            .is_some_and(|name| contains_node(name, node)),
842        "assignment" | "augmented_assignment" | "for_statement" | "for_in_clause" => parent
843            .child_by_field_name("left")
844            .is_some_and(|left| contains_node(left, node)),
845        "named_expression" => parent
846            .child_by_field_name("name")
847            .is_some_and(|name| contains_node(name, node)),
848        _ => false,
849    }
850}
851
852fn is_import_identifier(node: Node<'_>) -> bool {
853    let mut current = node;
854    while let Some(parent) = current.parent() {
855        if matches!(parent.kind(), "import_statement" | "import_from_statement") {
856            return true;
857        }
858        current = parent;
859    }
860    false
861}
862
863fn is_attribute_identifier(node: Node<'_>) -> bool {
864    let Some(parent) = node.parent() else {
865        return false;
866    };
867    parent.kind() == "attribute"
868        && parent
869            .child_by_field_name("attribute")
870            .is_some_and(|attribute| same_node(attribute, node))
871}
872
873fn is_pattern_identifier(node: Node<'_>) -> bool {
874    let mut current = node;
875    while let Some(parent) = current.parent() {
876        if parent.kind().contains("pattern") {
877            return true;
878        }
879        current = parent;
880    }
881    false
882}
883
884pub fn is_python_builtin_or_constant(name: &str) -> bool {
885    matches!(
886        name,
887        "None"
888            | "True"
889            | "False"
890            | "NotImplemented"
891            | "Ellipsis"
892            | "__annotations__"
893            | "__builtins__"
894            | "__debug__"
895            | "__doc__"
896            | "__file__"
897            | "__loader__"
898            | "__name__"
899            | "__package__"
900            | "__spec__"
901            | "ArithmeticError"
902            | "AssertionError"
903            | "AttributeError"
904            | "BaseException"
905            | "BaseExceptionGroup"
906            | "BlockingIOError"
907            | "BrokenPipeError"
908            | "BufferError"
909            | "BytesWarning"
910            | "ChildProcessError"
911            | "ConnectionAbortedError"
912            | "ConnectionError"
913            | "ConnectionRefusedError"
914            | "ConnectionResetError"
915            | "DeprecationWarning"
916            | "EOFError"
917            | "EncodingWarning"
918            | "EnvironmentError"
919            | "Exception"
920            | "ExceptionGroup"
921            | "FileExistsError"
922            | "FileNotFoundError"
923            | "FloatingPointError"
924            | "FutureWarning"
925            | "GeneratorExit"
926            | "IOError"
927            | "ImportError"
928            | "ImportWarning"
929            | "IndentationError"
930            | "IndexError"
931            | "InterruptedError"
932            | "IsADirectoryError"
933            | "KeyError"
934            | "KeyboardInterrupt"
935            | "LookupError"
936            | "MemoryError"
937            | "ModuleNotFoundError"
938            | "NameError"
939            | "NotADirectoryError"
940            | "NotImplementedError"
941            | "OSError"
942            | "OverflowError"
943            | "PendingDeprecationWarning"
944            | "PermissionError"
945            | "ProcessLookupError"
946            | "RecursionError"
947            | "ReferenceError"
948            | "ResourceWarning"
949            | "RuntimeError"
950            | "RuntimeWarning"
951            | "StopAsyncIteration"
952            | "StopIteration"
953            | "SyntaxError"
954            | "SyntaxWarning"
955            | "SystemError"
956            | "SystemExit"
957            | "TabError"
958            | "TimeoutError"
959            | "TypeError"
960            | "UnboundLocalError"
961            | "UnicodeDecodeError"
962            | "UnicodeEncodeError"
963            | "UnicodeError"
964            | "UnicodeTranslateError"
965            | "UnicodeWarning"
966            | "UserWarning"
967            | "ValueError"
968            | "Warning"
969            | "ZeroDivisionError"
970            | "abs"
971            | "aiter"
972            | "all"
973            | "anext"
974            | "any"
975            | "ascii"
976            | "bin"
977            | "bool"
978            | "breakpoint"
979            | "bytearray"
980            | "bytes"
981            | "callable"
982            | "chr"
983            | "classmethod"
984            | "compile"
985            | "complex"
986            | "copyright"
987            | "credits"
988            | "delattr"
989            | "dict"
990            | "dir"
991            | "divmod"
992            | "enumerate"
993            | "eval"
994            | "exec"
995            | "exit"
996            | "filter"
997            | "float"
998            | "format"
999            | "frozenset"
1000            | "getattr"
1001            | "hasattr"
1002            | "hash"
1003            | "help"
1004            | "hex"
1005            | "id"
1006            | "input"
1007            | "int"
1008            | "isinstance"
1009            | "issubclass"
1010            | "iter"
1011            | "len"
1012            | "license"
1013            | "list"
1014            | "locals"
1015            | "map"
1016            | "max"
1017            | "memoryview"
1018            | "min"
1019            | "next"
1020            | "object"
1021            | "oct"
1022            | "open"
1023            | "ord"
1024            | "pow"
1025            | "print"
1026            | "property"
1027            | "quit"
1028            | "range"
1029            | "repr"
1030            | "reversed"
1031            | "round"
1032            | "set"
1033            | "setattr"
1034            | "slice"
1035            | "sorted"
1036            | "staticmethod"
1037            | "str"
1038            | "sum"
1039            | "super"
1040            | "tuple"
1041            | "type"
1042            | "vars"
1043            | "zip"
1044            | "__import__"
1045            // Python 2 compatibility modules remain analyzable Python source.
1046            // These names are supplied by that runtime even though modern
1047            // Python no longer exposes them.
1048            | "apply"
1049            | "basestring"
1050            | "buffer"
1051            | "cmp"
1052            | "coerce"
1053            | "execfile"
1054            | "intern"
1055            | "long"
1056            | "raw_input"
1057            | "reduce"
1058            | "reload"
1059            | "unichr"
1060            | "unicode"
1061            | "xrange"
1062    )
1063}