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