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