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