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