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