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