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
232pub fn parse_quoted_include(line: &str) -> Option<String> {
233    let trimmed = line.trim();
234    let quote_start = trimmed.find('"')?;
235    let quote_end = trimmed[quote_start + 1..].find('"')?;
236    Some(trimmed[quote_start + 1..quote_start + 1 + quote_end].to_string())
237}
238
239pub fn parse_include_path(line: &str) -> Option<String> {
240    if let Some(path) = parse_quoted_include(line) {
241        return Some(path);
242    }
243    let trimmed = line.trim();
244    let angle_start = trimmed.find('<')?;
245    let angle_end = trimmed[angle_start + 1..].find('>')?;
246    Some(trimmed[angle_start + 1..angle_start + 1 + angle_end].to_string())
247}
248
249pub fn resolve_include_targets(
250    project: &dyn Project,
251    source_file: &ProjectFile,
252    include: &str,
253) -> Vec<ProjectFile> {
254    let mut candidates = Vec::new();
255    let source_root = project.root().to_path_buf();
256    for rel_path in direct_include_candidate_paths(project.root(), source_file, include) {
257        let file = ProjectFile::new(source_root.clone(), rel_path);
258        if file.exists() {
259            candidates.push(file);
260        }
261    }
262
263    candidates.sort();
264    candidates.dedup();
265    candidates
266}
267
268fn direct_include_candidate_paths(
269    root: &Path,
270    source_file: &ProjectFile,
271    include: &str,
272) -> Vec<PathBuf> {
273    let include_path = Path::new(include);
274    if include_path.is_absolute() {
275        return project_relative_include_path(root, include_path)
276            .into_iter()
277            .collect();
278    }
279    vec![
280        source_file.parent().join(include_path),
281        include_path.to_path_buf(),
282    ]
283}
284
285pub fn resolve_include_targets_with_index(
286    source_file: &ProjectFile,
287    include: &str,
288    include_targets: &IncludeTargetIndex,
289) -> Vec<ProjectFile> {
290    let mut candidates = include_targets.resolve_direct(source_file, include);
291    if !candidates.is_empty() {
292        return candidates;
293    }
294    if Path::new(include).is_absolute() {
295        return candidates;
296    }
297    candidates.extend(include_targets.resolve_unique_fallback(source_file, include));
298    if candidates.is_empty()
299        && let Some(template) = header_template_include_spelling(include)
300    {
301        candidates = include_targets.resolve_direct(source_file, &template);
302        if candidates.is_empty() {
303            candidates.extend(include_targets.resolve_unique_fallback(source_file, &template));
304        }
305    }
306    // Installed include spellings often add a public package directory that
307    // does not exist in the source tree, for example `<botan/asn1_obj.h>` for
308    // `src/lib/asn1/asn1_obj.h`. After exact paths and generated-header
309    // templates have both failed, a globally unique basename is still a
310    // structured, unambiguous target. Duplicate basenames remain unresolved.
311    if candidates.is_empty() {
312        candidates = include_targets.resolve_unique_basename_alias(include);
313    }
314    candidates
315}
316
317/// The `.hin` header-template spelling of an `.h` include, or `None` for any
318/// other include. A `.hin` file is the committed template the build turns
319/// into the like-named public header in the same directory -- krb5 generates
320/// `include/krb5/krb5.h` from `include/krb5/krb5.hin` (#2372) -- so in a tree
321/// without build artifacts the template is the only file holding those
322/// declarations. The retry runs only after every `.h` rule has failed, so a
323/// real header always wins over its template.
324fn header_template_include_spelling(include: &str) -> Option<String> {
325    let path = Path::new(include);
326    (path.extension() == Some(std::ffi::OsStr::new("h")))
327        .then(|| path.with_extension("hin").to_string_lossy().into_owned())
328}
329
330pub fn resolve_direct_include_targets_with_index(
331    source_file: &ProjectFile,
332    include: &str,
333    include_targets: &IncludeTargetIndex,
334) -> Vec<ProjectFile> {
335    include_targets.resolve_direct(source_file, include)
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
487pub fn quoted_include_paths(parsed: &[String]) -> Vec<String> {
488    parsed
489        .iter()
490        .filter_map(|line| parse_quoted_include(line))
491        .collect()
492}
493
494pub fn include_paths(parsed: &[String]) -> Vec<String> {
495    parsed
496        .iter()
497        .filter_map(|line| parse_include_path(line))
498        .collect()
499}
500
501/// Return the literal path and delimiter kind from one parsed `#include`.
502///
503/// The path is read from the grammar's `path` child, so malformed directives
504/// and arbitrary source text never become include facts. Only the fixed
505/// delimiters represented by tree-sitter's literal node kinds are removed.
506pub(crate) fn include_path_literal_value<'a>(path: Node<'_>, source: &'a str) -> Option<&'a str> {
507    let text = source.get(path.byte_range())?;
508    match path.kind() {
509        "string_literal" => text.strip_prefix('"')?.strip_suffix('"'),
510        "system_lib_string" => text.strip_prefix('<')?.strip_suffix('>'),
511        _ => None,
512    }
513}
514
515pub fn include_path_from_node<'a>(node: Node<'_>, source: &'a str) -> Option<(&'a str, bool)> {
516    if node.kind() != "preproc_include" {
517        return None;
518    }
519    let path = node.child_by_field_name("path")?;
520    let quoted = path.kind() == "string_literal";
521    let value = include_path_literal_value(path, source).filter(|path| !path.is_empty())?;
522    Some((value, quoted))
523}
524
525/// Collect capitalized identifier nodes from one live C++ syntax subtree.
526///
527/// This is intentionally an AST operation: the caller supplies the exact
528/// prepared declaration node and its matching source snapshot. The uppercase
529/// filter preserves the existing relevance policy while avoiding lexical
530/// reinterpretation of comments, literals, operators, and malformed text.
531pub fn extract_type_identifiers(node: Node<'_>, source: &str) -> BTreeSet<String> {
532    let mut identifiers = BTreeSet::new();
533    let mut stack = vec![node];
534    while let Some(current) = stack.pop() {
535        if matches!(
536            current.kind(),
537            "identifier" | "type_identifier" | "namespace_identifier"
538        ) && let Some(text) = source.get(current.byte_range())
539            && text
540                .chars()
541                .next()
542                .is_some_and(|character| character.is_ascii_uppercase())
543        {
544            identifiers.insert(text.to_owned());
545        }
546        let mut cursor = current.walk();
547        let children = current.named_children(&mut cursor).collect::<Vec<_>>();
548        stack.extend(children.into_iter().rev());
549    }
550    identifiers
551}
552
553/// Whether the structural receiver queries apply to `file`.
554///
555/// `Language::Cpp` also covers plain `.c`, which has no member-call receivers
556/// for those queries to resolve, so the route is gated on the extension.
557pub fn receiver_query_supported(file: &ProjectFile) -> bool {
558    file.rel_path()
559        .extension()
560        .and_then(|extension| extension.to_str())
561        != Some("c")
562}
563
564#[cfg(windows)]
565fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
566    let mut path_components = path.components();
567    for root_component in root.components() {
568        let path_component = path_components.next()?;
569        if !path_component
570            .as_os_str()
571            .eq_ignore_ascii_case(root_component.as_os_str())
572        {
573            return None;
574        }
575    }
576    Some(path_components.as_path().to_path_buf())
577}
578
579#[cfg(not(windows))]
580fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
581    path.strip_prefix(root).ok().map(Path::to_path_buf)
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use brokk_bifrost_core::test_support::write_file;
588    use tempfile::TempDir;
589
590    #[test]
591    #[brokk_bifrost_test_macros::hardwon(
592        "4b224ec22cdb: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
593    )]
594    fn indexed_include_resolution_uses_unique_suffix_fallback() {
595        let temp = TempDir::new().unwrap();
596        let root = temp.path().canonicalize().unwrap();
597        let source = write_file(&root, "src/lib.c", "");
598        let target = write_file(&root, "include/git2/sys/credential.h", "");
599        let duplicate = write_file(&root, "vendor/credential.h", "");
600        let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
601
602        let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
603        assert_eq!(resolved, vec![target]);
604
605        let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
606        assert!(ambiguous.is_empty());
607    }
608
609    #[test]
610    #[brokk_bifrost_test_macros::hardwon(
611        "cf45b06757c2: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
612    )]
613    fn indexed_include_resolution_prefers_unique_source_reachable_root() {
614        let temp = TempDir::new().unwrap();
615        let root = temp.path().canonicalize().unwrap();
616        let source = write_file(&root, "src/config/parse.c", "");
617        let target = write_file(&root, "src/config/parse.h", "");
618        let nested_decoy = write_file(&root, "src/build/config/parse.h", "");
619        let unrelated_source = write_file(&root, "app/main.c", "");
620        let index = IncludeTargetIndex::build([&source, &target, &nested_decoy, &unrelated_source]);
621
622        let resolved = resolve_include_targets_with_index(&source, "config/parse.h", &index);
623        assert_eq!(resolved, vec![target.clone()]);
624
625        let unrelated =
626            resolve_include_targets_with_index(&unrelated_source, "config/parse.h", &index);
627        assert!(unrelated.is_empty());
628
629        let ambiguous_source = write_file(&root, "src/config/deeper/main.c", "");
630        let second_reachable = write_file(&root, "src/config/config/parse.h", "");
631        let ambiguous_index = IncludeTargetIndex::build([
632            &ambiguous_source,
633            &target,
634            &nested_decoy,
635            &second_reachable,
636        ]);
637        let ambiguous = resolve_include_targets_with_index(
638            &ambiguous_source,
639            "config/parse.h",
640            &ambiguous_index,
641        );
642        assert!(ambiguous.is_empty());
643    }
644
645    #[test]
646    #[brokk_bifrost_test_macros::hardwon(
647        "d8cf28cfbabb: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
648    )]
649    fn indexed_include_resolution_accepts_one_unique_installed_prefix_alias() {
650        let temp = TempDir::new().unwrap();
651        let root = temp.path().canonicalize().unwrap();
652        let source = write_file(&root, "src/lib/asn1/asn1_obj.cpp", "");
653        let target = write_file(&root, "src/lib/asn1/asn1_obj.h", "");
654        let index = IncludeTargetIndex::build([&source, &target]);
655
656        assert_eq!(
657            resolve_include_targets_with_index(&source, "botan/asn1_obj.h", &index),
658            vec![target.clone()]
659        );
660
661        let duplicate = write_file(&root, "vendor/asn1_obj.h", "");
662        let ambiguous = IncludeTargetIndex::build([&source, &target, &duplicate]);
663        assert!(
664            resolve_include_targets_with_index(&source, "botan/asn1_obj.h", &ambiguous).is_empty()
665        );
666    }
667
668    #[test]
669    #[brokk_bifrost_test_macros::hardwon(
670        "53f52b136fc7: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
671    )]
672    fn unresolved_h_include_falls_back_to_hin_template() {
673        let temp = TempDir::new().unwrap();
674        let root = temp.path().canonicalize().unwrap();
675        let stub = write_file(&root, "src/include/krb5.h", "");
676        let template = write_file(&root, "src/include/krb5/krb5.hin", "");
677        let index = IncludeTargetIndex::build([&stub, &template]);
678
679        let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
680        assert_eq!(resolved, vec![template]);
681    }
682
683    #[test]
684    #[brokk_bifrost_test_macros::hardwon(
685        "53f52b136fc7: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
686    )]
687    fn real_header_wins_over_hin_template() {
688        let temp = TempDir::new().unwrap();
689        let root = temp.path().canonicalize().unwrap();
690        let stub = write_file(&root, "src/include/krb5.h", "");
691        let generated = write_file(&root, "src/include/krb5/krb5.h", "");
692        let template = write_file(&root, "src/include/krb5/krb5.hin", "");
693        let index = IncludeTargetIndex::build([&stub, &generated, &template]);
694
695        let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
696        assert_eq!(resolved, vec![generated]);
697    }
698}