Skip to main content

brokk_bifrost_cpp/
diagnostics.rs

1//! C++'s semantic diagnostics: unrecognized-type reporting with stated proof.
2//!
3//! The pass reports a missing type only where it can prove what a translation
4//! unit sees: a `compile_commands.json` entry with no forced or system
5//! includes, an `#include` closure of quoted project headers that all resolve
6//! and parse cleanly, and no preprocessor conditionals or macro definitions
7//! anywhere in that closure. That gate is unchanged. What changed in #1627 is
8//! that every way of failing it now states a typed
9//! [`SemanticDiagnosticIncompleteReason`] instead of returning silence: "this
10//! file has no unknown types" and "this file was never checked" are different
11//! answers, and only the first may be read as a clean bill of health.
12//!
13//! Nothing here runs a compiler, a build tool, or a system include scan. The
14//! compile database is the whole of the external evidence, so a file the
15//! database does not name is unjudgeable rather than clean.
16//!
17//! `analyzer/cpp/diagnostics.rs` in `brokk-bifrost-analysis` keeps only the
18//! fixtures that build a real `CppAnalyzer`.
19
20use crate::compile_context::CppCompileContext;
21use crate::graph_support::CppSource;
22use brokk_bifrost_core::analyzer::model::{
23    SemanticAbsenceProof, SemanticDiagnostic, SemanticDiagnosticDomain,
24    SemanticDiagnosticIncompleteReason, SemanticDiagnosticReport,
25};
26use brokk_bifrost_core::analyzer::semantic_diagnostics::{node_range, node_text};
27use brokk_bifrost_core::analyzer::structural::resolution::BoundaryStatus;
28use brokk_bifrost_core::analyzer::tree_walk::collect_parse_errors;
29use brokk_bifrost_core::analyzer::{ProjectFile, Range};
30use brokk_bifrost_core::hash::{HashMap, HashSet};
31use brokk_bifrost_core::path_utils::rel_path_string;
32use brokk_bifrost_core::text_utils::compute_line_starts;
33use std::path::Path;
34use tree_sitter::{Node, Parser, Tree};
35
36pub const CPP_UNRECOGNIZED_SYMBOL: &str = "cpp_unrecognized_symbol";
37pub const CPP_SEMANTIC_DIAGNOSTIC_SOURCE: &str = "bifrost-cpp";
38const MAX_CPP_SEMANTIC_DIAGNOSTIC_BYTES: usize = 512 * 1024;
39const MAX_CPP_SEMANTIC_DIAGNOSTICS: usize = 200;
40
41pub fn collect_cpp_semantic_diagnostics(
42    analyzer: &dyn CppSource,
43    file: &ProjectFile,
44    source: &str,
45) -> SemanticDiagnosticReport {
46    let mut report = SemanticDiagnosticReport::new();
47    if source.len() > MAX_CPP_SEMANTIC_DIAGNOSTIC_BYTES {
48        report.push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
49        return report;
50    }
51
52    let tree = parse_cpp_tree(source);
53    if has_parse_errors(tree.root_node()) {
54        // The parse errors themselves reach the host through the analyzer's
55        // parse-diagnostic path. What this report records is that the tree this
56        // pass would have judged is not trustworthy, so no name was checked.
57        report.push_incomplete(
58            None,
59            vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
60                detail: "C++ source has parse errors".to_string(),
61            }],
62        );
63        return report;
64    }
65
66    // The compile database is the only external evidence C++ has. A file it
67    // does not name may still be compiled, with flags and an include path this
68    // pass cannot see, so its boundary is unknown rather than empty.
69    let contexts = analyzer.compile_contexts_for(file);
70    if contexts.is_empty() {
71        report.push_incomplete(
72            None,
73            vec![
74                SemanticDiagnosticIncompleteReason::MissingDependencyDiscovery {
75                    boundary: BoundaryStatus::ExternalUnknown,
76                },
77            ],
78        );
79        return report;
80    }
81
82    let mut closures = Vec::with_capacity(contexts.len());
83    for context in contexts {
84        match prove_include_closure(file, source, context) {
85            Ok(closure) => closures.push((context, closure)),
86            // One unprovable configuration sinks the file: a name absent from
87            // the configurations that did prove out may well be present in the
88            // one that did not.
89            Err(reason) => {
90                report.push_incomplete(None, vec![reason]);
91                return report;
92            }
93        }
94    }
95
96    let line_starts = compute_line_starts(source);
97    let mut stack = vec![tree.root_node()];
98    while let Some(node) = stack.pop() {
99        if node.kind() == "type_identifier" && is_plain_type_reference(node) {
100            let name = node_text(node, source);
101            if !name.is_empty() {
102                if report.diagnostics().len() >= MAX_CPP_SEMANTIC_DIAGNOSTICS {
103                    report
104                        .push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
105                    break;
106                }
107                record_type_reference(&mut report, &closures, name, node_range(node, &line_starts));
108            }
109        }
110        push_named_children(&mut stack, node);
111    }
112    report
113}
114
115/// Judge one type reference against every proven closure and record what the
116/// closures could show.
117fn record_type_reference(
118    report: &mut SemanticDiagnosticReport,
119    closures: &[(&CppCompileContext, ProvenClosure)],
120    name: &str,
121    range: Range,
122) {
123    let mut resolutions = closures
124        .iter()
125        .map(|(context, closure)| closure.resolve(context, name));
126    let first = resolutions.next().expect("at least one proven closure");
127    if resolutions.any(|resolution| resolution != first) {
128        // The configurations disagree, so neither presence nor absence holds
129        // for the file as a whole. Naming the type keeps the report actionable.
130        report.push_incomplete(
131            Some(range),
132            vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
133                detail: format!("the compile commands for this file disagree about type `{name}`"),
134            }],
135        );
136        return;
137    }
138
139    match first {
140        NameResolution::CommandLineMacro => {
141            // `-DFoo=...` makes the build, not the closure, decide what this
142            // name spells. Expanding it would mean running the preprocessor.
143            report.push_incomplete(
144                Some(range),
145                vec![
146                    SemanticDiagnosticIncompleteReason::UnsupportedGeneratedSurface {
147                        detail: format!(
148                            "type name `{name}` is defined as a compile-command macro (-D{name})"
149                        ),
150                    },
151                ],
152            );
153        }
154        NameResolution::Declared { definition_sites } if definition_sites > 1 => {
155            // This pass matches on the bare name, so two definitions of it in
156            // the closure (two namespaces, most often) leave it unable to say
157            // which one the reference means. Both are workspace-local.
158            report.push_ambiguous(
159                range,
160                vec![BoundaryStatus::WorkspaceLocal; definition_sites],
161            );
162        }
163        NameResolution::Declared { .. } => {
164            report.push_resolved(range, BoundaryStatus::WorkspaceLocal);
165        }
166        NameResolution::Absent => {
167            // The proven closure is entirely project-local, so a name missing
168            // from it is missing from everything the translation unit sees.
169            report.push_absent(
170                SemanticAbsenceProof {
171                    range,
172                    domain: SemanticDiagnosticDomain::Type {
173                        name: name.to_string(),
174                    },
175                    boundary: BoundaryStatus::WorkspaceLocal,
176                },
177                SemanticDiagnostic {
178                    range,
179                    source: CPP_SEMANTIC_DIAGNOSTIC_SOURCE,
180                    kind: CPP_UNRECOGNIZED_SYMBOL,
181                    message: format!("Unrecognized C++ type `{name}`"),
182                },
183            );
184        }
185    }
186}
187
188/// What one compile configuration's include closure says about a name.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190enum NameResolution {
191    /// The compile command defines the name as a macro, so the closure is not
192    /// the thing that decides what it means.
193    CommandLineMacro,
194    /// No specifier in the closure names it.
195    Absent,
196    /// The closure names it, at `definition_sites` places that give it a body.
197    /// Zero sites means a forward declaration and nothing more, which still
198    /// makes the name a type this translation unit knows.
199    Declared { definition_sites: usize },
200}
201
202/// Every type name one compile configuration's `#include` closure introduces.
203#[derive(Debug, Default)]
204struct ProvenClosure {
205    /// Names introduced by any class, struct, union or enum specifier,
206    /// including forward declarations, which name a type without defining it.
207    declared: HashSet<String>,
208    /// How many specifiers with a body each name has. More than one leaves a
209    /// bare-name reference ambiguous.
210    definition_sites: HashMap<String, usize>,
211}
212
213impl ProvenClosure {
214    fn resolve(&self, context: &CppCompileContext, name: &str) -> NameResolution {
215        if context.defined_macros.contains(name) {
216            return NameResolution::CommandLineMacro;
217        }
218        if !self.declared.contains(name) {
219            return NameResolution::Absent;
220        }
221        NameResolution::Declared {
222            definition_sites: self.definition_sites.get(name).copied().unwrap_or(0),
223        }
224    }
225}
226
227fn parse_cpp_tree(source: &str) -> Tree {
228    let mut parser = Parser::new();
229    parser
230        .set_language(&tree_sitter_cpp::LANGUAGE.into())
231        .expect("the linked tree-sitter-cpp grammar matches this tree-sitter version");
232    // Parsing returns `None` only for a cancelled or timed-out parse, and this
233    // pass sets neither.
234    parser.parse(source, None).expect("uncancelled C++ parse")
235}
236
237fn has_parse_errors(root: Node<'_>) -> bool {
238    let mut errors = Vec::new();
239    collect_parse_errors(root, &mut errors);
240    !errors.is_empty()
241}
242
243/// Walk the `#include` closure of one compile configuration, collecting every
244/// type name it introduces, or state why the closure cannot be reproduced.
245///
246/// The walk uses an explicit stack for both the file queue and the node walk,
247/// so neither a deep include chain nor a deeply nested AST recurses.
248fn prove_include_closure(
249    source_file: &ProjectFile,
250    source: &str,
251    context: &CppCompileContext,
252) -> Result<ProvenClosure, SemanticDiagnosticIncompleteReason> {
253    // A forced include or a system root puts headers in front of this file that
254    // the closure below cannot read, so the closure would not be the one the
255    // compiler sees.
256    if let Some(forced) = context.forced_includes.first() {
257        return Err(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
258            detail: format!(
259                "the compile command forces `-include {}`, whose declarations this pass cannot read",
260                forced.display()
261            ),
262        });
263    }
264    if let Some(root) = context.system_include_roots.first() {
265        return Err(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
266            detail: format!(
267                "the compile command adds system include root `{}`, whose declarations this pass cannot read",
268                root.display()
269            ),
270        });
271    }
272
273    let mut closure = ProvenClosure::default();
274    let mut visited = HashSet::default();
275    let mut pending = vec![(source_file.clone(), source.to_string())];
276    while let Some((file, source)) = pending.pop() {
277        if !visited.insert(file.abs_path()) {
278            continue;
279        }
280        let tree = parse_cpp_tree(&source);
281        if has_parse_errors(tree.root_node()) {
282            debug_assert_ne!(
283                file.abs_path(),
284                source_file.abs_path(),
285                "the entry file's parse errors are reported before the closure walk starts"
286            );
287            return Err(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
288                detail: format!(
289                    "included header `{}` has parse errors",
290                    rel_path_string(&file)
291                ),
292            });
293        }
294        let mut stack = vec![tree.root_node()];
295        while let Some(node) = stack.pop() {
296            match node.kind() {
297                "preproc_include" => {
298                    let Some(include) = quoted_include_path(node, &source) else {
299                        // An angle-bracket or computed include names a header
300                        // the build supplies and nothing here indexes.
301                        return Err(
302                            SemanticDiagnosticIncompleteReason::MissingDependencyDiscovery {
303                                boundary: BoundaryStatus::ExternalDeclaredUnindexed,
304                            },
305                        );
306                    };
307                    let header = resolve_project_header(&file, &include, context)?;
308                    let Ok(header_source) = header.read_to_string() else {
309                        return Err(
310                            SemanticDiagnosticIncompleteReason::MissingDependencyDiscovery {
311                                boundary: BoundaryStatus::ExternalDeclaredUnindexed,
312                            },
313                        );
314                    };
315                    pending.push((header, header_source));
316                }
317                "preproc_def" | "preproc_function_def" => {
318                    // A macro can spell, rename or generate a type name. This
319                    // pass does not expand macros, so its view of the closure
320                    // would be a guess.
321                    let name = node
322                        .child_by_field_name("name")
323                        .map(|name| node_text(name, &source))
324                        .unwrap_or_default();
325                    return Err(
326                        SemanticDiagnosticIncompleteReason::UnsupportedGeneratedSurface {
327                            detail: format!(
328                                "`#define {name}` in `{}` can generate type names this pass does not expand",
329                                rel_path_string(&file)
330                            ),
331                        },
332                    );
333                }
334                "preproc_if" | "preproc_ifdef" | "preproc_ifndef" | "preproc_elif"
335                | "preproc_else" => {
336                    // Which declarations survive depends on preprocessor state
337                    // this pass does not evaluate. Include guards are the
338                    // common benign case and are still refused: recognizing one
339                    // means proving it guards the whole file and nothing else.
340                    return Err(SemanticDiagnosticIncompleteReason::DynamicBehavior {
341                        detail: format!(
342                            "conditional compilation `{}` in `{}` selects declarations this pass does not evaluate",
343                            directive_keyword(node, &source),
344                            rel_path_string(&file)
345                        ),
346                    });
347                }
348                kind if kind.starts_with("preproc_") => {
349                    // Closed by default: an unclassified directive is refused
350                    // rather than walked past, so a grammar that grows a new
351                    // one cannot silently weaken the proof.
352                    return Err(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
353                        detail: format!(
354                            "unsupported preprocessor directive `{}` in `{}`",
355                            directive_keyword(node, &source),
356                            rel_path_string(&file)
357                        ),
358                    });
359                }
360                "class_specifier" | "struct_specifier" | "union_specifier" | "enum_specifier" => {
361                    if let Some(name) = declared_type_name(node, &source) {
362                        if node.child_by_field_name("body").is_some() {
363                            *closure.definition_sites.entry(name.clone()).or_default() += 1;
364                        }
365                        closure.declared.insert(name);
366                    }
367                    push_named_children(&mut stack, node);
368                }
369                "type_definition" | "alias_declaration" => {
370                    // `typedef int Meters;` and `using Meters = int;` introduce
371                    // a type name as surely as a struct does.
372                    for name in alias_type_names(node, &source) {
373                        closure.declared.insert(name);
374                    }
375                    push_named_children(&mut stack, node);
376                }
377                _ => push_named_children(&mut stack, node),
378            }
379        }
380    }
381    Ok(closure)
382}
383
384fn declared_type_name(node: Node<'_>, source: &str) -> Option<String> {
385    let name = node.child_by_field_name("name").or_else(|| {
386        let mut cursor = node.walk();
387        node.named_children(&mut cursor)
388            .find(|child| matches!(child.kind(), "type_identifier" | "identifier"))
389    })?;
390    let name = node_text(name, source).trim();
391    (!name.is_empty()).then(|| name.to_string())
392}
393
394/// The type names a `typedef` or a `using` alias binds.
395///
396/// A single `typedef` can bind several names at once (`typedef int A, B;`), so
397/// this reads every declarator the node carries rather than just the first.
398fn alias_type_names(node: Node<'_>, source: &str) -> Vec<String> {
399    let mut names = Vec::new();
400    let mut stack: Vec<Node<'_>> = node
401        .child_by_field_name("name")
402        .into_iter()
403        .chain({
404            let mut cursor = node.walk();
405            node.children_by_field_name("declarator", &mut cursor)
406                .collect::<Vec<_>>()
407        })
408        .collect();
409    // A declarator wraps the bound name in pointer, array and function layers;
410    // the name is the `type_identifier` at the bottom of that chain.
411    while let Some(current) = stack.pop() {
412        if current.kind() == "type_identifier" {
413            let name = node_text(current, source).trim();
414            if !name.is_empty() {
415                names.push(name.to_string());
416            }
417            continue;
418        }
419        push_named_children(&mut stack, current);
420    }
421    names
422}
423
424/// The directive keyword that opens a preprocessor node, read from the tree.
425fn directive_keyword(node: Node<'_>, source: &str) -> String {
426    node.child(0)
427        .map(|token| node_text(token, source).trim().to_string())
428        .filter(|token| !token.is_empty())
429        .unwrap_or_else(|| node.kind().to_string())
430}
431
432fn quoted_include_path(node: Node<'_>, source: &str) -> Option<String> {
433    let mut cursor = node.walk();
434    let literal = node
435        .named_children(&mut cursor)
436        .find(|child| child.kind() == "string_literal")?;
437    let text = node_text(literal, source);
438    text.strip_prefix('"')?
439        .strip_suffix('"')
440        .filter(|path| !path.is_empty())
441        .map(ToOwned::to_owned)
442}
443
444fn resolve_project_header(
445    source_file: &ProjectFile,
446    include: &str,
447    context: &CppCompileContext,
448) -> Result<ProjectFile, SemanticDiagnosticIncompleteReason> {
449    let mut candidates = HashSet::default();
450    if let Some(source_parent) = source_file.abs_path().parent().map(Path::to_path_buf) {
451        for root in
452            std::iter::once(source_parent).chain(context.project_include_roots.iter().cloned())
453        {
454            let candidate = root.join(include);
455            if candidate.is_file() && candidate.starts_with(source_file.root()) {
456                candidates.insert(candidate);
457            }
458        }
459    }
460    match candidates.len() {
461        // The build declares the header; no project file supplies it, so its
462        // declarations exist somewhere nothing here has indexed.
463        0 => Err(
464            SemanticDiagnosticIncompleteReason::MissingDependencyDiscovery {
465                boundary: BoundaryStatus::ExternalDeclaredUnindexed,
466            },
467        ),
468        1 => Ok(ProjectFile::new(
469            source_file.root().to_path_buf(),
470            candidates
471                .into_iter()
472                .next()
473                .expect("one candidate")
474                .strip_prefix(source_file.root())
475                .expect("candidate inside project")
476                .to_path_buf(),
477        )),
478        // Several include roots supply the same name. Picking one would mean
479        // reimplementing the compiler's search order.
480        _ => Err(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
481            detail: format!(
482                "`#include \"{include}\"` matches more than one project header on the include path"
483            ),
484        }),
485    }
486}
487
488fn is_plain_type_reference(node: Node<'_>) -> bool {
489    let Some(parent) = node.parent() else {
490        return false;
491    };
492    if !matches!(
493        parent.kind(),
494        "declaration" | "type_descriptor" | "sized_type_specifier"
495    ) {
496        return false;
497    }
498    let mut current = parent;
499    while let Some(ancestor) = current.parent() {
500        if matches!(
501            ancestor.kind(),
502            "class_specifier"
503                | "struct_specifier"
504                | "union_specifier"
505                | "enum_specifier"
506                | "template_declaration"
507                | "template_parameter_list"
508                | "template_type"
509                | "qualified_identifier"
510                | "scoped_type_identifier"
511        ) {
512            return false;
513        }
514        current = ancestor;
515    }
516    true
517}
518
519fn push_named_children<'tree>(stack: &mut Vec<Node<'tree>>, node: Node<'tree>) {
520    let mut cursor = node.walk();
521    let children: Vec<_> = node.named_children(&mut cursor).collect();
522    stack.extend(children.into_iter().rev());
523}