Skip to main content

brokk_bifrost_cpp/
adapter.rs

1//! The C++ answers behind `CppAdapter`.
2//!
3//! `LanguageAdapter` is analysis-owned, so the trait impl itself stays in
4//! `analyzer/cpp/adapter.rs`; every answer it gives comes from here or from
5//! [`crate::test_detection`] and [`crate::queries`].
6
7use crate::declarations::{
8    CppVisitor, collect_cpp_identifiers, collect_cpp_includes, recover_quoted_includes,
9};
10use brokk_bifrost_core::analyzer::ProjectFile;
11use brokk_bifrost_core::analyzer::cognitive_complexity;
12use brokk_bifrost_core::analyzer::model::{Language, LanguageDialect};
13use brokk_bifrost_core::analyzer::parsed_file::ParsedFile;
14use brokk_bifrost_core::analyzer::tree_walk::ParentIndex;
15use brokk_bifrost_core::hash::HashMap;
16use std::sync::LazyLock;
17use tree_sitter::{Node, Tree};
18
19/// The file extension `CppAdapter` reports. `Language::Cpp` also covers `.c`,
20/// `.cc`, `.cxx` and the header spellings; this is only the canonical one.
21pub const CPP_FILE_EXTENSION: &str = "cpp";
22
23/// Tree-sitter node-kind mapping used by the cognitive-complexity scorer for
24/// C++. Node names are from the tree-sitter-cpp grammar.
25pub static CPP_COGNITIVE_CONFIG: LazyLock<cognitive_complexity::Config> =
26    LazyLock::new(|| cognitive_complexity::Config {
27        if_types: &["if_statement"],
28        loop_types: &["for_statement", "while_statement", "do_statement"],
29        catch_types: &["catch_clause"],
30        conditional_types: &["conditional_expression"],
31        case_types: &["case_statement"],
32        binary_types: &["binary_expression"],
33        logical_operators: &["&&", "||", "and", "or"],
34        jump_types: &["break_statement", "continue_statement"],
35        named_function_boundary_types: &["function_definition"],
36        anonymous_function_types: &["lambda_expression"],
37        else_clause_types: &["else_clause"],
38        default_case_predicate: Some(cpp_is_default_case),
39        ..cognitive_complexity::Config::empty()
40    });
41
42fn cpp_is_default_case(node: Node<'_>, _source: &str) -> bool {
43    node.child_by_field_name("value").is_none()
44}
45
46/// Extract `file` under the dialect its own path selects.
47pub fn parse_cpp_file(file: &ProjectFile, source: &str, tree: &Tree) -> ParsedFile {
48    parse_cpp_file_in_dialect(
49        file,
50        source,
51        tree,
52        LanguageDialect::for_path(Language::Cpp, file.rel_path()),
53    )
54}
55
56/// Extract `file` under an explicitly named dialect.
57///
58/// A header carries no compilation language of its own, so its blob has two
59/// legitimate readings: under [`LanguageDialect::CppC`] a tag declared inside
60/// an aggregate member list has file scope (C17 6.2.1), under the plain C++
61/// dialect it is a nested class. Milestone 3 of
62/// `.agents/plans/c-compilation-language-tag-scope.md` stores both readings of
63/// a header when they differ, so extraction has to be reachable under a
64/// dialect the path itself does not name.
65///
66/// This entry point owns the whole tree's work, including the parent index the
67/// walk asks its ancestor questions of. A caller that wants both readings of
68/// one tree must use [`parse_cpp_file_with_ancestry`] and
69/// [`parse_cpp_c_reading`] instead, which share that index.
70pub fn parse_cpp_file_in_dialect(
71    file: &ProjectFile,
72    source: &str,
73    tree: &Tree,
74    dialect: LanguageDialect,
75) -> ParsedFile {
76    let root = tree.root_node();
77    let ancestry = ParentIndex::new(root);
78    parse_cpp_reading(file, source, root, dialect, &ancestry)
79}
80
81/// Extract `file` under the dialect its own path selects, asking its ancestor
82/// questions of a caller-owned index over the same tree.
83///
84/// The index costs one hash entry per node and is a property of the tree, not
85/// of the reading, so a header that gets both readings builds it once. See
86/// [`parse_cpp_c_reading`] for the second reading.
87pub fn parse_cpp_file_with_ancestry<'tree>(
88    file: &ProjectFile,
89    source: &str,
90    root: Node<'tree>,
91    ancestry: &ParentIndex<'tree>,
92) -> ParsedFile {
93    parse_cpp_reading(
94        file,
95        source,
96        root,
97        LanguageDialect::for_path(Language::Cpp, file.rel_path()),
98        ancestry,
99    )
100}
101
102/// The C reading of a tree whose other reading is already in hand.
103///
104/// The dialect decides exactly one thing: where a struct/union/enum tag
105/// declared inside another aggregate's member list is declared (the
106/// `c_tag_semantics` readers in [`crate::declarations`]). Everything else this
107/// file's extraction produces is a property of the tree and of the source text
108/// -- the parent index, the `#include` sweep, the identifier sweep, and the
109/// quoted-include line recovery -- so the C reading takes those from `primary`
110/// rather than recomputing them over the same bytes. A debug build re-runs the
111/// sweeps and asserts they agree, which is what holds the claim honest if a
112/// future walk ever starts contributing to one of those families.
113pub fn parse_cpp_c_reading<'tree>(
114    file: &ProjectFile,
115    source: &str,
116    root: Node<'tree>,
117    ancestry: &ParentIndex<'tree>,
118    primary: &ParsedFile,
119) -> ParsedFile {
120    let mut parsed = ParsedFile::new(String::new());
121    parsed.imports = primary.imports.clone();
122    parsed.type_identifiers = primary.type_identifiers.clone();
123    walk_cpp_declarations(
124        file,
125        source,
126        root,
127        LanguageDialect::CppC,
128        ancestry,
129        &mut parsed,
130    );
131    parsed.finalize_deferred_replacements();
132
133    #[cfg(debug_assertions)]
134    {
135        let mut recomputed = ParsedFile::new(String::new());
136        collect_cpp_includes(root, source, &mut recomputed);
137        collect_cpp_identifiers(root, source, &mut recomputed.type_identifiers);
138        recover_quoted_includes(source, &mut recomputed);
139        assert_eq!(
140            parsed.imports, recomputed.imports,
141            "the C reading's includes are the C++ reading's includes: {:?}",
142            file
143        );
144        assert_eq!(
145            parsed.type_identifiers, recomputed.type_identifiers,
146            "the C reading's identifiers are the C++ reading's identifiers: {:?}",
147            file
148        );
149    }
150
151    parsed
152}
153
154/// One complete reading of `root`, sweeps included.
155fn parse_cpp_reading<'tree>(
156    file: &ProjectFile,
157    source: &str,
158    root: Node<'tree>,
159    dialect: LanguageDialect,
160    ancestry: &ParentIndex<'tree>,
161) -> ParsedFile {
162    let mut parsed = ParsedFile::new(String::new());
163
164    collect_cpp_includes(root, source, &mut parsed);
165    collect_cpp_identifiers(root, source, &mut parsed.type_identifiers);
166
167    walk_cpp_declarations(file, source, root, dialect, ancestry, &mut parsed);
168    // A line scan over the source rather than a tree walk: it recovers the
169    // quoted directives a parse error hid from the tree, skipping any snippet
170    // the sweep above already recorded.
171    recover_quoted_includes(source, &mut parsed);
172    parsed.finalize_deferred_replacements();
173    parsed
174}
175
176/// The declaration walk itself: the only part of an extraction the dialect
177/// changes. The caller finalizes, because the primary reading recovers its
178/// quoted includes between the walk and that compaction.
179fn walk_cpp_declarations<'tree>(
180    file: &ProjectFile,
181    source: &str,
182    root: Node<'tree>,
183    dialect: LanguageDialect,
184    ancestry: &ParentIndex<'tree>,
185    parsed: &mut ParsedFile,
186) {
187    let mut visitor = CppVisitor {
188        file,
189        source,
190        parsed,
191        c_tag_semantics: dialect == LanguageDialect::CppC,
192        recovered_class_sibling_scopes: HashMap::default(),
193        consumed_fragment_regions: Vec::new(),
194        namespace_forward_scans: HashMap::default(),
195        field_owners: None,
196        recovery_captures: Vec::new(),
197    };
198    visitor.visit_container(root, ancestry, "", None, None, None, Vec::new());
199}
200
201/// Whether two readings of one blob disagree about any identity-bearing
202/// output: which declarations exist, what they are named, which are top level
203/// or definition-lookup entries, where they start and end, what they are
204/// nested in, and how they are signed.
205///
206/// This is the "differs" test behind storing a header's C projection only when
207/// it says something the C++ projection does not (issue #1970): absence of the
208/// second row-set must unambiguously mean "identical", so anything a
209/// resolution surface can observe has to be compared here.
210pub fn cpp_projections_differ(left: &ParsedFile, right: &ParsedFile) -> bool {
211    left.declarations() != right.declarations()
212        || left.top_level_declarations != right.top_level_declarations
213        || left.definition_lookup_units != right.definition_lookup_units
214        || left.children != right.children
215        || left.ranges != right.ranges
216        || left.signatures != right.signatures
217        || left.type_aliases != right.type_aliases
218}
219
220pub fn cpp_extract_call_receiver(reference: &str) -> Option<String> {
221    let trimmed = reference.trim();
222    let before_args = trimmed
223        .split_once('(')
224        .map(|(head, _)| head)
225        .unwrap_or(trimmed);
226    before_args
227        .rsplit_once("::")
228        .or_else(|| before_args.rsplit_once('.'))
229        .map(|(receiver, _)| receiver.to_string())
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use tree_sitter::Parser;
236
237    fn cpp_tree(source: &str) -> tree_sitter::Tree {
238        let mut parser = Parser::new();
239        parser
240            .set_language(&tree_sitter_cpp::LANGUAGE.into())
241            .expect("C++ grammar");
242        parser.parse(source, None).expect("C++ tree")
243    }
244
245    /// Everything one reading publishes, rendered so that two readings can be
246    /// compared without depending on hash iteration order.
247    fn published_facts(parsed: &ParsedFile) -> Vec<String> {
248        let mut facts = vec![
249            format!("package={}", parsed.package_name),
250            format!("content_qualifier={}", parsed.content_qualifier),
251            format!("top_level={:?}", parsed.top_level_declarations),
252            format!("imports={:?}", parsed.imports),
253            format!("materializations={:?}", parsed.materialization_records),
254            format!("rust_usage_facts={:?}", parsed.rust_usage_facts),
255        ];
256        let mut unordered = |label: &str, mut entries: Vec<String>| {
257            entries.sort();
258            facts.push(format!("{label}={entries:?}"));
259        };
260        unordered(
261            "declarations",
262            parsed.declarations().iter().map(debug_of).collect(),
263        );
264        unordered(
265            "definition_lookup",
266            parsed
267                .definition_lookup_units
268                .iter()
269                .map(debug_of)
270                .collect(),
271        );
272        unordered(
273            "type_identifiers",
274            parsed.type_identifiers.iter().map(debug_of).collect(),
275        );
276        unordered(
277            "type_aliases",
278            parsed.type_aliases.iter().map(debug_of).collect(),
279        );
280        unordered(
281            "scala_traits",
282            parsed.scala_traits.iter().map(debug_of).collect(),
283        );
284        unordered(
285            "test_region_units",
286            parsed.test_region_units.iter().map(debug_of).collect(),
287        );
288        unordered(
289            "navigation_truncated",
290            parsed
291                .navigation_ranges_truncated
292                .iter()
293                .map(debug_of)
294                .collect(),
295        );
296        unordered("children", pairs(&parsed.children));
297        unordered("ranges", pairs(&parsed.ranges));
298        unordered("navigation_ranges", pairs(&parsed.navigation_ranges));
299        unordered("signatures", pairs(&parsed.signatures));
300        unordered("signature_metadata", pairs(&parsed.signature_metadata));
301        unordered("raw_supertypes", pairs(&parsed.raw_supertypes));
302        unordered(
303            "supertype_lookup_paths",
304            pairs(&parsed.supertype_lookup_paths),
305        );
306        unordered("scala_exports", pairs(&parsed.scala_exports));
307        unordered(
308            "cpp_template_metadata",
309            pairs(&parsed.cpp_template_metadata),
310        );
311        unordered(
312            "ruby_method_dispatch_modes",
313            pairs(&parsed.ruby_method_dispatch_modes),
314        );
315        facts
316    }
317
318    fn debug_of<T: std::fmt::Debug>(value: T) -> String {
319        format!("{value:?}")
320    }
321
322    fn pairs<K: std::fmt::Debug, V: std::fmt::Debug>(
323        map: &brokk_bifrost_core::hash::HashMap<K, V>,
324    ) -> Vec<String> {
325        map.iter().map(|entry| format!("{entry:?}")).collect()
326    }
327
328    /// The two readings of one header, taken the way production takes them,
329    /// publish exactly what two independent extractions publish.
330    ///
331    /// Milestone 3b of `.agents/plans/immutable-revision-persisted-fact-reuse.md`
332    /// stopped the C reading from rebuilding the parent index, re-sweeping
333    /// includes and identifiers, and re-running the quoted-include line scan
334    /// that the C++ reading of the same tree had already produced. That is a
335    /// deduplication and nothing else: if any published fact moved, something
336    /// believed dialect-insensitive is not.
337    ///
338    /// The error-recovery shapes are the interesting half. Their walks reparse
339    /// byte regions into trees of their own and re-own sibling nodes under
340    /// recovered class scopes, so they are where a shared index would show up
341    /// if sharing were unsound.
342    #[test]
343    fn a_shared_reading_publishes_what_an_independent_one_publishes() {
344        let fixtures: &[(&str, &str)] = &[
345            (
346                "nested tag inside an aggregate, plus a nested include",
347                r#"
348#include <vector>
349struct outer {
350#include "member_list.def"
351    struct inner { int v; } i;
352};
353struct inner *p;
354"#,
355            ),
356            (
357                "a quoted include only the line scan can recover",
358                r#"
359#include "visible.h"
360class Broken {
361    void method(
362#include "hidden.h"
363"#,
364            ),
365            (
366                "forward declarations replaced by their definitions",
367                r#"
368typedef unsigned long long u64;
369namespace generated {
370struct tag0;
371struct tag1;
372struct tag1 {
373    struct nested { int v; } n;
374    u64 first;
375};
376struct tag0 { int second; };
377}
378"#,
379            ),
380            (
381                "a fragmented export-macro class body",
382                r#"
383#define SIMPLECPP_LIB
384namespace simplecpp {
385using TokenString = std::string;
386struct Location { int line{}; };
387class SIMPLECPP_LIB Token {
388  TokenString prefix;
389  void prefix_method() {}
390 public:
391  Token(const TokenString &s, const Location &loc, bool wsahead = false) :
392      whitespaceahead(wsahead), location(loc), string(s)
393      {
394      flags();
395  }
396  struct Nested { int v; } nested;
397  TokenString string;
398  bool whitespaceahead;
399  Location location;
400 private:
401  void flags() {
402      whitespaceahead = true;
403  }
404};
405}
406"#,
407            ),
408        ];
409
410        for (name, source) in fixtures {
411            let file = ProjectFile::new(
412                std::env::current_dir().expect("test working directory must be available"),
413                "src/widget.h",
414            );
415            let tree = cpp_tree(source);
416            let root = tree.root_node();
417
418            let independent_primary = parse_cpp_file(&file, source, &tree);
419            let independent_c =
420                parse_cpp_file_in_dialect(&file, source, &tree, LanguageDialect::CppC);
421
422            let ancestry = ParentIndex::new(root);
423            let shared_primary = parse_cpp_file_with_ancestry(&file, source, root, &ancestry);
424            let shared_c = parse_cpp_c_reading(&file, source, root, &ancestry, &shared_primary);
425
426            assert_eq!(
427                published_facts(&independent_primary),
428                published_facts(&shared_primary),
429                "C++ reading of {name}"
430            );
431            assert_eq!(
432                published_facts(&independent_c),
433                published_facts(&shared_c),
434                "C reading of {name}"
435            );
436        }
437    }
438
439    /// A tag nested in an aggregate is the whole reason the second reading
440    /// exists, so the fixture above must actually produce two different
441    /// readings; otherwise the comparison would pass on two empty answers.
442    #[test]
443    fn the_nested_tag_fixture_really_has_two_readings() {
444        let source = "struct outer { struct inner { int v; } i; };\nstruct inner *p;\n";
445        let file = ProjectFile::new(
446            std::env::current_dir().expect("test working directory must be available"),
447            "src/widget.h",
448        );
449        let tree = cpp_tree(source);
450        let root = tree.root_node();
451        let ancestry = ParentIndex::new(root);
452        let primary = parse_cpp_file_with_ancestry(&file, source, root, &ancestry);
453        let c_reading = parse_cpp_c_reading(&file, source, root, &ancestry, &primary);
454        assert!(
455            cpp_projections_differ(&primary, &c_reading),
456            "the C reading should mint `inner` at file scope: {:#?} vs {:#?}",
457            primary.declarations(),
458            c_reading.declarations()
459        );
460    }
461
462    /// Every `#include` is an include claim, wherever it is written. The
463    /// declaration walk descends only through declaration scopes, so a
464    /// directive inside a function body (llama.cpp's `sycl/info/aspects.def`
465    /// inside a `switch`) or inside a class body (Eigen's
466    /// `EIGEN_DENSEBASE_PLUGIN` in `DenseBase.h`) used to be invisible.
467    ///
468    /// The nested directive here is not a quoted include, so the established
469    /// `recover_quoted_includes` line scan cannot supply it: only the preorder
470    /// sweep over the tree can.
471    #[test]
472    fn includes_are_recorded_at_every_depth() {
473        let source = r#"
474#include <vector>
475
476class Widget {
477public:
478    int value() const;
479};
480
481int run() {
482    switch (0) {
483#include <sycl/info/aspects.def>
484    default:
485        return 0;
486    }
487}
488"#;
489        let file = ProjectFile::new(
490            std::env::current_dir().expect("test working directory must be available"),
491            "src/widget.cpp",
492        );
493        let mut parser = Parser::new();
494        parser
495            .set_language(&tree_sitter_cpp::LANGUAGE.into())
496            .expect("C++ grammar");
497        let tree = parser.parse(source, None).expect("C++ tree");
498
499        let parsed = parse_cpp_file(&file, source, &tree);
500        let includes = parsed
501            .imports
502            .iter()
503            .map(|import| import.raw_snippet.clone())
504            .collect::<Vec<_>>();
505
506        assert_eq!(
507            includes,
508            vec![
509                "#include <vector>".to_string(),
510                "#include <sycl/info/aspects.def>".to_string(),
511            ]
512        );
513        assert!(!parsed.declarations().is_empty());
514    }
515}