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