Skip to main content

brokk_bifrost_cpp/
imports.rs

1//! `#include` parsing and the workspace-wide include-target index.
2//!
3//! `analyzer/cpp/imports.rs` in `brokk-bifrost-analysis` keeps the
4//! `ImportAnalysisProvider` / `TestDetectionProvider` impls and the `OnceLock` /
5//! `PoolSafeMemo` cells that memoize [`IncludeTargetIndex`] and the reverse
6//! include map on the analyzer; every decision they make is a function here.
7
8use brokk_bifrost_core::analyzer::ProjectFile;
9use brokk_bifrost_core::analyzer::model::{ImportInfo, Language};
10use brokk_bifrost_core::analyzer::project::Project;
11use brokk_bifrost_core::hash::{HashMap, HashSet};
12use brokk_bifrost_core::path_normalization::NormalizePath;
13use brokk_bifrost_core::path_utils::path_suffix_key;
14use std::collections::BTreeSet;
15use std::path::{Path, PathBuf};
16use tree_sitter::Node;
17
18use crate::source_facts::CppFileSourceFacts;
19
20/// Extensions [`Language::Cpp`] claims that are headers, not translation
21/// units. Everything else in [`Language::Cpp::extensions`] (`c`, `cc`, `cpp`,
22/// `cxx`) is a translation unit -- see [`is_cpp_translation_unit`]. `hin` is
23/// the C header-template spelling (krb5's `krb5.hin`), so it is a header too.
24const CPP_HEADER_EXTENSIONS: &[&str] = &["h", "hin", "hpp", "hh", "hxx"];
25
26/// Whether `file` is a C/C++ translation unit (as opposed to a header): one of
27/// [`Language::Cpp::extensions`] that is not a header extension.
28/// Matched case-insensitively, the same normalization `Language::from_extension`
29/// applies.
30pub fn is_cpp_translation_unit(file: &ProjectFile) -> bool {
31    file.rel_path()
32        .extension()
33        .and_then(|extension| extension.to_str())
34        .map(|extension| extension.to_ascii_lowercase())
35        .is_some_and(|extension| {
36            Language::Cpp.extensions().contains(&extension.as_str())
37                && !CPP_HEADER_EXTENSIONS.contains(&extension.as_str())
38        })
39}
40
41/// Workspace-wide resolution table for `#include` targets: every analyzable file
42/// keyed both by its full workspace-relative path and by its bare file name.
43///
44/// Built once per analyzer generation from `all_files()` and consulted by every
45/// include-visibility walk, so a header's dependents resolve without a
46/// filesystem probe per include line.
47pub struct IncludeTargetIndex {
48    by_rel_path: HashMap<PathBuf, Vec<ProjectFile>>,
49    by_file_name: HashMap<String, Vec<ProjectFile>>,
50}
51
52/// The include paths published by the primary C++ source collector.
53///
54/// A missing source-facts mount is deliberately different from an empty
55/// include list. Consumers use `None` to preserve the unavailable-facts
56/// boundary rather than reparsing the source text at query time.
57pub fn canonical_include_paths(facts: &CppFileSourceFacts) -> Vec<String> {
58    facts
59        .facts
60        .includes
61        .iter()
62        .map(|include| include.path.clone())
63        .collect()
64}
65
66/// Render canonical include facts for legacy APIs that still expose complete
67/// directive spellings. The path itself comes from the AST-owned fact; this
68/// function does not parse source text.
69pub fn canonical_include_statements(facts: &CppFileSourceFacts) -> Vec<String> {
70    facts
71        .facts
72        .includes
73        .iter()
74        .map(|include| canonical_include_statement(&include.path, include.quoted))
75        .collect()
76}
77
78/// Render one canonical include fact as a directive spelling.
79pub fn canonical_include_statement(path: &str, quoted: bool) -> String {
80    if quoted {
81        format!("#include \"{path}\"")
82    } else {
83        format!("#include <{path}>")
84    }
85}
86
87impl IncludeTargetIndex {
88    pub fn build<'a>(files: impl IntoIterator<Item = &'a ProjectFile>) -> Self {
89        let mut by_rel_path: HashMap<PathBuf, Vec<ProjectFile>> = HashMap::default();
90        let mut by_file_name: HashMap<String, Vec<ProjectFile>> = HashMap::default();
91        for file in files {
92            by_rel_path
93                .entry(file.rel_path().to_path_buf())
94                .or_default()
95                .push(file.clone());
96            if let Some(file_name) = file.rel_path().file_name().and_then(|value| value.to_str()) {
97                by_file_name
98                    .entry(file_name.to_string())
99                    .or_default()
100                    .push(file.clone());
101            }
102        }
103        Self {
104            by_rel_path,
105            by_file_name,
106        }
107    }
108
109    pub fn resolve_indexed(&self, include: &str) -> Vec<ProjectFile> {
110        let include_path = Path::new(include);
111        let mut matched = HashSet::default();
112        let mut resolved = Vec::new();
113        if let Some(targets) = self.by_rel_path.get(include_path) {
114            for target in targets {
115                if matched.insert(target.clone()) {
116                    resolved.push(target.clone());
117                }
118            }
119        }
120        for suffix in string_suffixes(include) {
121            if let Some(targets) = self.by_file_name.get(suffix) {
122                for target in targets {
123                    if matched.insert(target.clone()) {
124                        resolved.push(target.clone());
125                    }
126                }
127            }
128        }
129        resolved
130    }
131
132    /// Whether any analyzable workspace file could answer `include`: some
133    /// indexed file's name equals the include's final path component.
134    ///
135    /// An include no indexed file can answer names a header the build supplies
136    /// from outside the analyzed sources, so nothing in this index is hidden by
137    /// failing to resolve it. Callers that otherwise fail closed on an
138    /// unresolved include use this to separate that case from a spelling the
139    /// index does hold but cannot narrow to one target.
140    pub fn names_indexed_file(&self, include: &str) -> bool {
141        Path::new(include)
142            .file_name()
143            .and_then(|name| name.to_str())
144            .is_some_and(|name| self.by_file_name.contains_key(name))
145    }
146
147    fn resolve_direct(&self, source_file: &ProjectFile, include: &str) -> Vec<ProjectFile> {
148        let mut matched = HashSet::default();
149        let mut resolved = Vec::new();
150        for rel_path in direct_include_candidate_paths(source_file.root(), source_file, include) {
151            self.extend_rel_path(&rel_path, &mut matched, &mut resolved);
152        }
153        resolved
154    }
155
156    fn extend_rel_path(
157        &self,
158        rel_path: &Path,
159        matched: &mut HashSet<ProjectFile>,
160        out: &mut Vec<ProjectFile>,
161    ) {
162        if let Some(targets) = self.by_rel_path.get(rel_path) {
163            for target in targets {
164                if matched.insert(target.clone()) {
165                    out.push(target.clone());
166                }
167            }
168        }
169    }
170
171    fn resolve_unique_fallback(
172        &self,
173        source_file: &ProjectFile,
174        include: &str,
175    ) -> Vec<ProjectFile> {
176        let include_path = Path::new(include);
177        let indexed = self.resolve_indexed(include);
178        let matches: Vec<_> = indexed
179            .into_iter()
180            .filter(|file| {
181                if include_path.components().count() > 1 {
182                    file.rel_path().ends_with(include_path)
183                } else {
184                    file.rel_path()
185                        .file_name()
186                        .is_some_and(|name| name == include_path)
187                }
188            })
189            .collect();
190        if matches.len() == 1 {
191            return matches;
192        }
193        let source_reachable = matches
194            .into_iter()
195            .filter(|file| {
196                (0..include_path.components().count())
197                    .try_fold(file.rel_path(), |path, _| path.parent())
198                    .is_some_and(|root| source_file.rel_path().starts_with(root))
199            })
200            .collect::<Vec<_>>();
201        if source_reachable.len() == 1 {
202            return source_reachable;
203        }
204        Vec::new()
205    }
206
207    fn resolve_unique_basename_alias(&self, include: &str) -> Vec<ProjectFile> {
208        let include_path = Path::new(include);
209        if include_path.components().count() <= 1 {
210            return Vec::new();
211        }
212        let Some(file_name) = include_path.file_name() else {
213            return Vec::new();
214        };
215        let matches = self
216            .resolve_indexed(include)
217            .into_iter()
218            .filter(|file| file.rel_path().file_name() == Some(file_name))
219            .collect::<Vec<_>>();
220        if matches.len() == 1 {
221            matches
222        } else {
223            Vec::new()
224        }
225    }
226}
227
228fn string_suffixes(value: &str) -> impl Iterator<Item = &str> {
229    value.char_indices().map(|(index, _)| &value[index..])
230}
231
232// Claim discovery receives the generic import projection before canonical
233// source facts are mounted. Parse that boundary with the same include grammar;
234// quoted text in a comment or an angle directive is never a claim edge.
235fn parse_quoted_include(statement: &str) -> Option<String> {
236    let tree = crate::diagnostics::parse_cpp_tree(statement);
237    let mut cursor = tree.root_node().walk();
238    tree.root_node()
239        .named_children(&mut cursor)
240        .find_map(|node| {
241            let (path, quoted) = include_path_from_node(node, statement)?;
242            quoted.then(|| path.to_owned())
243        })
244}
245
246pub fn resolve_include_targets(
247    project: &dyn Project,
248    source_file: &ProjectFile,
249    include: &str,
250) -> Vec<ProjectFile> {
251    let mut candidates = Vec::new();
252    let source_root = project.root().to_path_buf();
253    for rel_path in direct_include_candidate_paths(project.root(), source_file, include) {
254        let file = ProjectFile::new(source_root.clone(), rel_path);
255        if file.exists() {
256            candidates.push(file);
257        }
258    }
259
260    candidates.sort();
261    candidates.dedup();
262    candidates
263}
264
265fn direct_include_candidate_paths(
266    root: &Path,
267    source_file: &ProjectFile,
268    include: &str,
269) -> Vec<PathBuf> {
270    let include_path = Path::new(include);
271    if include_path.is_absolute() {
272        return project_relative_include_path(root, include_path)
273            .into_iter()
274            .collect();
275    }
276    vec![
277        source_file.parent().join(include_path),
278        include_path.to_path_buf(),
279    ]
280}
281
282pub fn resolve_include_targets_with_index(
283    source_file: &ProjectFile,
284    include: &str,
285    include_targets: &IncludeTargetIndex,
286) -> Vec<ProjectFile> {
287    let mut candidates = include_targets.resolve_direct(source_file, include);
288    if !candidates.is_empty() {
289        return candidates;
290    }
291    if Path::new(include).is_absolute() {
292        return candidates;
293    }
294    candidates.extend(include_targets.resolve_unique_fallback(source_file, include));
295    if candidates.is_empty()
296        && let Some(template) = header_template_include_spelling(include)
297    {
298        candidates = include_targets.resolve_direct(source_file, &template);
299        if candidates.is_empty() {
300            candidates.extend(include_targets.resolve_unique_fallback(source_file, &template));
301        }
302    }
303    // Installed include spellings often add a public package directory that
304    // does not exist in the source tree, for example `<botan/asn1_obj.h>` for
305    // `src/lib/asn1/asn1_obj.h`. After exact paths and generated-header
306    // templates have both failed, a globally unique basename is still a
307    // structured, unambiguous target. Duplicate basenames remain unresolved.
308    if candidates.is_empty() {
309        candidates = include_targets.resolve_unique_basename_alias(include);
310    }
311    candidates
312}
313
314/// The `.hin` header-template spelling of an `.h` include, or `None` for any
315/// other include. A `.hin` file is the committed template the build turns
316/// into the like-named public header in the same directory -- krb5 generates
317/// `include/krb5/krb5.h` from `include/krb5/krb5.hin` (#2372) -- so in a tree
318/// without build artifacts the template is the only file holding those
319/// declarations. The retry runs only after every `.h` rule has failed, so a
320/// real header always wins over its template.
321fn header_template_include_spelling(include: &str) -> Option<String> {
322    let path = Path::new(include);
323    (path.extension() == Some(std::ffi::OsStr::new("h")))
324        .then(|| path.with_extension("hin").to_string_lossy().into_owned())
325}
326
327fn project_relative_include_path(project_root: &Path, include_path: &Path) -> Option<PathBuf> {
328    let canonical_root = project_root
329        .canonicalize()
330        .unwrap_or_else(|_| project_root.to_path_buf())
331        .normalize();
332    let canonical_include = include_path
333        .canonicalize()
334        .unwrap_or_else(|_| include_path.to_path_buf())
335        .normalize();
336    strip_path_prefix(&canonical_include, &canonical_root).or_else(|| {
337        strip_path_prefix(
338            &include_path.to_path_buf().normalize(),
339            &project_root.to_path_buf().normalize(),
340        )
341    })
342}
343
344/// The claim edges `sources` contribute: for each source file, the workspace
345/// files it pulls in by quoted `#include` that no language's extension registry
346/// claims (#1837).
347///
348/// `sources` pairs each already-analyzed C++ file with the `ImportInfo` rows
349/// recorded for it; `claimable` is the caller's set of workspace files with an
350/// extension no language owns. `abseil`'s `.inc` translation-unit fragments are
351/// the motivating case: nothing indexes them today, so every declaration they
352/// hold is invisible in both directions.
353///
354/// Only quoted includes participate. An angled include names a search path the
355/// analyzer does not model, so resolving one against workspace file names would
356/// claim files the compiler would never reach.
357///
358/// Edges rather than a flat set, because the caller both closes the relation
359/// transitively and drops a claim when the last `#include` naming it goes away;
360/// both need to know which source contributed which target. A source with no
361/// claimable include contributes no entry.
362///
363/// The result depends only on `sources`, `claimable` and the resolution rules
364/// in this module -- never on the order either collection arrives in.
365pub fn included_claimable_files(
366    sources: &[(ProjectFile, Vec<ImportInfo>)],
367    claimable: &BTreeSet<ProjectFile>,
368) -> HashMap<ProjectFile, BTreeSet<ProjectFile>> {
369    let mut edges: HashMap<ProjectFile, BTreeSet<ProjectFile>> = HashMap::default();
370    if claimable.is_empty() || sources.is_empty() {
371        return edges;
372    }
373    let index = IncludeTargetIndex::build(claimable.iter());
374    for (source_file, imports) in sources {
375        let mut targets = BTreeSet::new();
376        for include in imports
377            .iter()
378            .filter_map(|import| parse_quoted_include(&import.raw_snippet))
379        {
380            targets.extend(resolve_include_targets_with_index(
381                source_file,
382                &include,
383                &index,
384            ));
385        }
386        if !targets.is_empty() {
387            edges.insert(source_file.clone(), targets);
388        }
389    }
390    edges
391}
392
393/// The claim demand `sources` record at the imports tier (#1865): for each
394/// source file, the target keys a workspace file that does not exist yet would
395/// have to match for one of that source's quoted `#include` lines to reach it.
396///
397/// Recorded alongside [`included_claimable_files`] and consulted when a new
398/// file appears: an update that sees a created `.md`/`.txt`/`.json` in a C++
399/// workspace re-derives the claim relation only when the created path answers
400/// recorded demand, instead of re-deriving it for the whole analyzed set every
401/// time (#1865, the blanket branch in `TreeSitterAnalyzer::update`).
402///
403/// Completeness, not precision, is what this must have: the caller uses a hit
404/// to decide whether to run the real resolution, so a key that matches a file
405/// resolution would reject costs one derivation, while a missing key would
406/// leave a genuinely included file unindexed until the next full build. Three
407/// deliberate widenings follow from that:
408///
409/// - every quoted include contributes, resolved or not. `resolve_direct`
410///   returns *all* rel-path matches, so a created file can add a target to an
411///   include that already resolved, and `resolve_unique_fallback`'s uniqueness
412///   test can flip in either direction when a candidate appears.
413/// - the key is a path suffix, which is exactly what the includer-relative
414///   rule, the project-relative rule and the `ends_with`/file-name fallback all
415///   reduce to.
416/// - `.h` includes contribute their `.hin` header-template spelling
417///   (`header_template_include_spelling`), which is the form a claimable file
418///   can actually have.
419///
420/// The one narrowing is sound rather than heuristic: a key whose extension some
421/// language's registry claims is dropped, because only a file with an unclaimed
422/// extension is ever claimable, and every match rule preserves the file name.
423/// It is what keeps the record proportional to a workspace's `.inc`-shaped
424/// includes rather than to its include count.
425pub fn claimable_include_demand(
426    sources: &[(ProjectFile, Vec<ImportInfo>)],
427) -> HashMap<ProjectFile, BTreeSet<String>> {
428    let mut demand: HashMap<ProjectFile, BTreeSet<String>> = HashMap::default();
429    for (source_file, imports) in sources {
430        let mut keys = BTreeSet::new();
431        for include in imports
432            .iter()
433            .filter_map(|import| parse_quoted_include(&import.raw_snippet))
434        {
435            let template = header_template_include_spelling(&include);
436            for spelling in std::iter::once(include).chain(template) {
437                collect_include_demand_keys(source_file, &spelling, &mut keys);
438            }
439        }
440        if !keys.is_empty() {
441            demand.insert(source_file.clone(), keys);
442        }
443    }
444    demand
445}
446
447fn collect_include_demand_keys(
448    source_file: &ProjectFile,
449    include: &str,
450    keys: &mut BTreeSet<String>,
451) {
452    let include_path = Path::new(include);
453    let claimable_spelling = include_path
454        .extension()
455        .and_then(|extension| extension.to_str())
456        .is_none_or(|extension| !Language::is_source_extension(extension));
457    if !claimable_spelling {
458        return;
459    }
460    if include_path.is_absolute() {
461        // An absolute include names a path outside the workspace-relative
462        // suffix relation, so its only key is the projection
463        // `IncludeTargetIndex::resolve_direct` itself takes.
464        if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
465            && let Some(key) = path_suffix_key(&rel_path)
466        {
467            keys.insert(key);
468        }
469        return;
470    }
471    if let Some(key) = path_suffix_key(include_path) {
472        keys.insert(key);
473    }
474}
475
476/// Return the literal path and delimiter kind from one parsed `#include`.
477///
478/// The path is read from the grammar's `path` child, so malformed directives
479/// and arbitrary source text never become include facts. Only the fixed
480/// delimiters represented by tree-sitter's literal node kinds are removed.
481pub(crate) fn include_path_literal_value<'a>(path: Node<'_>, source: &'a str) -> Option<&'a str> {
482    let text = source.get(path.byte_range())?;
483    match path.kind() {
484        "string_literal" => text.strip_prefix('"')?.strip_suffix('"'),
485        "system_lib_string" => text.strip_prefix('<')?.strip_suffix('>'),
486        _ => None,
487    }
488}
489
490pub fn include_path_from_node<'a>(node: Node<'_>, source: &'a str) -> Option<(&'a str, bool)> {
491    if node.kind() != "preproc_include" {
492        return None;
493    }
494    let path = node.child_by_field_name("path")?;
495    let quoted = path.kind() == "string_literal";
496    let value = include_path_literal_value(path, source).filter(|path| !path.is_empty())?;
497    Some((value, quoted))
498}
499
500/// Collect capitalized identifier nodes from one live C++ syntax subtree.
501///
502/// This is intentionally an AST operation: the caller supplies the exact
503/// prepared declaration node and its matching source snapshot. The uppercase
504/// filter preserves the existing relevance policy while avoiding lexical
505/// reinterpretation of comments, literals, operators, and malformed text.
506pub fn extract_type_identifiers(node: Node<'_>, source: &str) -> BTreeSet<String> {
507    let mut identifiers = BTreeSet::new();
508    let mut stack = vec![node];
509    while let Some(current) = stack.pop() {
510        if matches!(
511            current.kind(),
512            "identifier" | "type_identifier" | "namespace_identifier"
513        ) && let Some(text) = source.get(current.byte_range())
514            && text
515                .chars()
516                .next()
517                .is_some_and(|character| character.is_ascii_uppercase())
518        {
519            identifiers.insert(text.to_owned());
520        }
521        let mut cursor = current.walk();
522        let children = current.named_children(&mut cursor).collect::<Vec<_>>();
523        stack.extend(children.into_iter().rev());
524    }
525    identifiers
526}
527
528/// Whether the structural receiver queries apply to `file`.
529///
530/// `Language::Cpp` also covers plain `.c`, which has no member-call receivers
531/// for those queries to resolve, so the route is gated on the extension.
532pub fn receiver_query_supported(file: &ProjectFile) -> bool {
533    file.rel_path()
534        .extension()
535        .and_then(|extension| extension.to_str())
536        != Some("c")
537}
538
539#[cfg(windows)]
540fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
541    let mut path_components = path.components();
542    for root_component in root.components() {
543        let path_component = path_components.next()?;
544        if !path_component
545            .as_os_str()
546            .eq_ignore_ascii_case(root_component.as_os_str())
547        {
548            return None;
549        }
550    }
551    Some(path_components.as_path().to_path_buf())
552}
553
554#[cfg(not(windows))]
555fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
556    path.strip_prefix(root).ok().map(Path::to_path_buf)
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use brokk_bifrost_core::test_support::write_file;
563    use tempfile::TempDir;
564
565    #[test]
566    #[brokk_bifrost_test_macros::hardwon(
567        "db00de0653f1: quicksilver round 4 on brokk-bifrost-cpp: angle include comments fabricated quoted claim paths"
568    )]
569    fn quoted_include_uses_the_directive_path_node() {
570        assert_eq!(
571            parse_quoted_include("#include <fields.inc> // \"bogus.inc\""),
572            None
573        );
574        assert_eq!(
575            parse_quoted_include("#include /* \"bogus.inc\" */ \"real.inc\""),
576            Some("real.inc".to_string())
577        );
578        assert_eq!(parse_quoted_include("// #include \"bogus.inc\""), None);
579    }
580
581    #[test]
582    #[brokk_bifrost_test_macros::hardwon(
583        "4b224ec22cdb: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
584    )]
585    fn indexed_include_resolution_uses_unique_suffix_fallback() {
586        let temp = TempDir::new().unwrap();
587        let root = temp.path().canonicalize().unwrap();
588        let source = write_file(&root, "src/lib.c", "");
589        let target = write_file(&root, "include/git2/sys/credential.h", "");
590        let duplicate = write_file(&root, "vendor/credential.h", "");
591        let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
592
593        let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
594        assert_eq!(resolved, vec![target]);
595
596        let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
597        assert!(ambiguous.is_empty());
598    }
599
600    #[test]
601    #[brokk_bifrost_test_macros::hardwon(
602        "cf45b06757c2: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
603    )]
604    fn indexed_include_resolution_prefers_unique_source_reachable_root() {
605        let temp = TempDir::new().unwrap();
606        let root = temp.path().canonicalize().unwrap();
607        let source = write_file(&root, "src/config/parse.c", "");
608        let target = write_file(&root, "src/config/parse.h", "");
609        let nested_decoy = write_file(&root, "src/build/config/parse.h", "");
610        let unrelated_source = write_file(&root, "app/main.c", "");
611        let index = IncludeTargetIndex::build([&source, &target, &nested_decoy, &unrelated_source]);
612
613        let resolved = resolve_include_targets_with_index(&source, "config/parse.h", &index);
614        assert_eq!(resolved, vec![target.clone()]);
615
616        let unrelated =
617            resolve_include_targets_with_index(&unrelated_source, "config/parse.h", &index);
618        assert!(unrelated.is_empty());
619
620        let ambiguous_source = write_file(&root, "src/config/deeper/main.c", "");
621        let second_reachable = write_file(&root, "src/config/config/parse.h", "");
622        let ambiguous_index = IncludeTargetIndex::build([
623            &ambiguous_source,
624            &target,
625            &nested_decoy,
626            &second_reachable,
627        ]);
628        let ambiguous = resolve_include_targets_with_index(
629            &ambiguous_source,
630            "config/parse.h",
631            &ambiguous_index,
632        );
633        assert!(ambiguous.is_empty());
634    }
635
636    #[test]
637    #[brokk_bifrost_test_macros::hardwon(
638        "d8cf28cfbabb: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
639    )]
640    fn indexed_include_resolution_accepts_one_unique_installed_prefix_alias() {
641        let temp = TempDir::new().unwrap();
642        let root = temp.path().canonicalize().unwrap();
643        let source = write_file(&root, "src/lib/asn1/asn1_obj.cpp", "");
644        let target = write_file(&root, "src/lib/asn1/Widget.h", "");
645        let index = IncludeTargetIndex::build([&source, &target]);
646
647        assert_eq!(
648            resolve_include_targets_with_index(&source, "botan/Widget.h", &index),
649            vec![target.clone()]
650        );
651
652        let duplicate = write_file(&root, "vendor/Widget.h", "");
653        let ambiguous = IncludeTargetIndex::build([&source, &target, &duplicate]);
654        assert!(
655            resolve_include_targets_with_index(&source, "botan/Widget.h", &ambiguous).is_empty()
656        );
657    }
658
659    #[test]
660    #[brokk_bifrost_test_macros::hardwon(
661        "53f52b136fc7: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
662    )]
663    fn unresolved_h_include_falls_back_to_hin_template() {
664        let temp = TempDir::new().unwrap();
665        let root = temp.path().canonicalize().unwrap();
666        let stub = write_file(&root, "src/include/krb5.h", "");
667        let template = write_file(&root, "src/include/krb5/krb5.hin", "");
668        let index = IncludeTargetIndex::build([&stub, &template]);
669
670        let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
671        assert_eq!(resolved, vec![template]);
672    }
673
674    #[test]
675    #[brokk_bifrost_test_macros::hardwon(
676        "53f52b136fc7: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
677    )]
678    fn real_header_wins_over_hin_template() {
679        let temp = TempDir::new().unwrap();
680        let root = temp.path().canonicalize().unwrap();
681        let stub = write_file(&root, "src/include/krb5.h", "");
682        let generated = write_file(&root, "src/include/krb5/krb5.h", "");
683        let template = write_file(&root, "src/include/krb5/krb5.hin", "");
684        let index = IncludeTargetIndex::build([&stub, &generated, &template]);
685
686        let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
687        assert_eq!(resolved, vec![generated]);
688    }
689
690    #[test]
691    fn include_literal_grammar_boundary() {
692        for (source, kind, quoted) in [
693            ("#include \"fields.inc\"\n", "string_literal", true),
694            ("#include <fields.inc>\n", "system_lib_string", false),
695        ] {
696            let mut parser = tree_sitter::Parser::new();
697            parser
698                .set_language(&tree_sitter_cpp::LANGUAGE.into())
699                .unwrap();
700            let tree = parser.parse(source, None).unwrap();
701            assert!(!tree.root_node().has_error());
702            let directive = tree.root_node().named_child(0).unwrap();
703            assert_eq!(directive.kind(), "preproc_include");
704            let path = directive.child_by_field_name("path").unwrap();
705            assert_eq!((path.kind(), path.byte_range()), (kind, 9..21));
706            assert_eq!(
707                include_path_from_node(directive, source),
708                Some(("fields.inc", quoted))
709            );
710        }
711    }
712}