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;
10use brokk_bifrost_core::analyzer::project::Project;
11use brokk_bifrost_core::hash::{HashMap, HashSet};
12use regex::Regex;
13use std::collections::BTreeSet;
14use std::path::{Path, PathBuf};
15use std::sync::OnceLock;
16
17/// Workspace-wide resolution table for `#include` targets: every analyzable file
18/// keyed both by its full workspace-relative path and by its bare file name.
19///
20/// Built once per analyzer generation from `all_files()` and consulted by every
21/// include-visibility walk, so a header's dependents resolve without a
22/// filesystem probe per include line.
23pub struct IncludeTargetIndex {
24    by_rel_path: HashMap<PathBuf, Vec<ProjectFile>>,
25    by_file_name: HashMap<String, Vec<ProjectFile>>,
26}
27
28impl IncludeTargetIndex {
29    pub fn build<'a>(files: impl IntoIterator<Item = &'a ProjectFile>) -> Self {
30        let mut by_rel_path: HashMap<PathBuf, Vec<ProjectFile>> = HashMap::default();
31        let mut by_file_name: HashMap<String, Vec<ProjectFile>> = HashMap::default();
32        for file in files {
33            by_rel_path
34                .entry(file.rel_path().to_path_buf())
35                .or_default()
36                .push(file.clone());
37            if let Some(file_name) = file.rel_path().file_name().and_then(|value| value.to_str()) {
38                by_file_name
39                    .entry(file_name.to_string())
40                    .or_default()
41                    .push(file.clone());
42            }
43        }
44        Self {
45            by_rel_path,
46            by_file_name,
47        }
48    }
49
50    pub fn resolve_indexed(&self, include: &str) -> Vec<ProjectFile> {
51        let include_path = Path::new(include);
52        let mut matched = HashSet::default();
53        let mut resolved = Vec::new();
54        if let Some(targets) = self.by_rel_path.get(include_path) {
55            for target in targets {
56                if matched.insert(target.clone()) {
57                    resolved.push(target.clone());
58                }
59            }
60        }
61        for suffix in string_suffixes(include) {
62            if let Some(targets) = self.by_file_name.get(suffix) {
63                for target in targets {
64                    if matched.insert(target.clone()) {
65                        resolved.push(target.clone());
66                    }
67                }
68            }
69        }
70        resolved
71    }
72
73    fn resolve_direct(&self, source_file: &ProjectFile, include: &str) -> Vec<ProjectFile> {
74        let include_path = Path::new(include);
75        let mut matched = HashSet::default();
76        let mut resolved = Vec::new();
77        if include_path.is_absolute() {
78            if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
79            {
80                self.extend_rel_path(&rel_path, &mut matched, &mut resolved);
81            }
82            return resolved;
83        }
84
85        let source_relative = ProjectFile::new(
86            source_file.root().to_path_buf(),
87            source_file.parent().join(include_path),
88        );
89        self.extend_rel_path(source_relative.rel_path(), &mut matched, &mut resolved);
90
91        let project_relative =
92            ProjectFile::new(source_file.root().to_path_buf(), include_path.to_path_buf());
93        self.extend_rel_path(project_relative.rel_path(), &mut matched, &mut resolved);
94        resolved
95    }
96
97    fn extend_rel_path(
98        &self,
99        rel_path: &Path,
100        matched: &mut HashSet<ProjectFile>,
101        out: &mut Vec<ProjectFile>,
102    ) {
103        if let Some(targets) = self.by_rel_path.get(rel_path) {
104            for target in targets {
105                if matched.insert(target.clone()) {
106                    out.push(target.clone());
107                }
108            }
109        }
110    }
111
112    fn resolve_unique_fallback(
113        &self,
114        source_file: &ProjectFile,
115        include: &str,
116    ) -> Vec<ProjectFile> {
117        let include_path = Path::new(include);
118        let matches: Vec<_> = self
119            .resolve_indexed(include)
120            .into_iter()
121            .filter(|file| {
122                if include_path.components().count() > 1 {
123                    file.rel_path().ends_with(include_path)
124                } else {
125                    file.rel_path()
126                        .file_name()
127                        .is_some_and(|name| name == include_path)
128                }
129            })
130            .collect();
131        if matches.len() == 1 {
132            return matches;
133        }
134        let source_reachable = matches
135            .into_iter()
136            .filter(|file| {
137                (0..include_path.components().count())
138                    .try_fold(file.rel_path(), |path, _| path.parent())
139                    .is_some_and(|root| source_file.rel_path().starts_with(root))
140            })
141            .collect::<Vec<_>>();
142        if source_reachable.len() == 1 {
143            source_reachable
144        } else {
145            Vec::new()
146        }
147    }
148}
149
150fn string_suffixes(value: &str) -> impl Iterator<Item = &str> {
151    value.char_indices().map(|(index, _)| &value[index..])
152}
153
154pub fn parse_quoted_include(line: &str) -> Option<String> {
155    let trimmed = line.trim();
156    let quote_start = trimmed.find('"')?;
157    let quote_end = trimmed[quote_start + 1..].find('"')?;
158    Some(trimmed[quote_start + 1..quote_start + 1 + quote_end].to_string())
159}
160
161pub fn parse_include_path(line: &str) -> Option<String> {
162    if let Some(path) = parse_quoted_include(line) {
163        return Some(path);
164    }
165    let trimmed = line.trim();
166    let angle_start = trimmed.find('<')?;
167    let angle_end = trimmed[angle_start + 1..].find('>')?;
168    Some(trimmed[angle_start + 1..angle_start + 1 + angle_end].to_string())
169}
170
171pub fn resolve_include_targets(
172    project: &dyn Project,
173    source_file: &ProjectFile,
174    include: &str,
175) -> Vec<ProjectFile> {
176    let mut candidates = Vec::new();
177    let include_path = Path::new(include);
178    let source_root = project.root().to_path_buf();
179    let relative_path = if include_path.is_absolute() {
180        match project_relative_include_path(project.root(), include_path) {
181            Some(path) => path,
182            None => return candidates,
183        }
184    } else {
185        source_file.parent().join(include_path)
186    };
187    let relative_file = ProjectFile::new(source_root.clone(), relative_path);
188    if relative_file.exists() {
189        candidates.push(relative_file);
190    }
191    if !include_path.is_absolute() {
192        let project_relative_file = ProjectFile::new(source_root.clone(), include_path);
193        if project_relative_file.exists() {
194            candidates.push(project_relative_file);
195        }
196    }
197
198    candidates.sort();
199    candidates.dedup();
200    candidates
201}
202
203pub fn resolve_include_targets_with_index(
204    source_file: &ProjectFile,
205    include: &str,
206    include_targets: &IncludeTargetIndex,
207) -> Vec<ProjectFile> {
208    let mut candidates = include_targets.resolve_direct(source_file, include);
209    if !candidates.is_empty() {
210        return candidates;
211    }
212    if Path::new(include).is_absolute() {
213        return candidates;
214    }
215    candidates.extend(include_targets.resolve_unique_fallback(source_file, include));
216    candidates
217}
218
219pub fn resolve_direct_include_targets_with_index(
220    source_file: &ProjectFile,
221    include: &str,
222    include_targets: &IncludeTargetIndex,
223) -> Vec<ProjectFile> {
224    include_targets.resolve_direct(source_file, include)
225}
226
227fn project_relative_include_path(project_root: &Path, include_path: &Path) -> Option<PathBuf> {
228    let canonical_root = project_root
229        .canonicalize()
230        .unwrap_or_else(|_| project_root.to_path_buf());
231    let canonical_include = include_path
232        .canonicalize()
233        .unwrap_or_else(|_| include_path.to_path_buf());
234    canonical_include
235        .strip_prefix(&canonical_root)
236        .map(Path::to_path_buf)
237        .or_else(|_| {
238            include_path
239                .strip_prefix(project_root)
240                .map(Path::to_path_buf)
241        })
242        .ok()
243        .or_else(|| lexical_project_relative_include_path(&canonical_root, &canonical_include))
244        .or_else(|| lexical_project_relative_include_path(project_root, include_path))
245}
246
247/// The claim edges `sources` contribute: for each source file, the workspace
248/// files it pulls in by quoted `#include` that no language's extension registry
249/// claims (#1837).
250///
251/// `sources` pairs each already-analyzed C++ file with the `ImportInfo` rows
252/// recorded for it; `claimable` is the caller's set of workspace files with an
253/// extension no language owns. `abseil`'s `.inc` translation-unit fragments are
254/// the motivating case: nothing indexes them today, so every declaration they
255/// hold is invisible in both directions.
256///
257/// Only quoted includes participate. An angled include names a search path the
258/// analyzer does not model, so resolving one against workspace file names would
259/// claim files the compiler would never reach.
260///
261/// Edges rather than a flat set, because the caller both closes the relation
262/// transitively and drops a claim when the last `#include` naming it goes away;
263/// both need to know which source contributed which target. A source with no
264/// claimable include contributes no entry.
265///
266/// The result depends only on `sources`, `claimable` and the resolution rules
267/// in this module -- never on the order either collection arrives in.
268pub fn included_claimable_files(
269    sources: &[(ProjectFile, Vec<ImportInfo>)],
270    claimable: &BTreeSet<ProjectFile>,
271) -> HashMap<ProjectFile, BTreeSet<ProjectFile>> {
272    let mut edges: HashMap<ProjectFile, BTreeSet<ProjectFile>> = HashMap::default();
273    if claimable.is_empty() || sources.is_empty() {
274        return edges;
275    }
276    let index = IncludeTargetIndex::build(claimable.iter());
277    for (source_file, imports) in sources {
278        let mut targets = BTreeSet::new();
279        for include in imports
280            .iter()
281            .filter_map(|import| parse_quoted_include(&import.raw_snippet))
282        {
283            targets.extend(resolve_include_targets_with_index(
284                source_file,
285                &include,
286                &index,
287            ));
288        }
289        if !targets.is_empty() {
290            edges.insert(source_file.clone(), targets);
291        }
292    }
293    edges
294}
295
296pub fn quoted_include_paths(parsed: &[String]) -> Vec<String> {
297    parsed
298        .iter()
299        .filter_map(|line| parse_quoted_include(line))
300        .collect()
301}
302
303pub fn include_paths(parsed: &[String]) -> Vec<String> {
304    parsed
305        .iter()
306        .filter_map(|line| parse_include_path(line))
307        .collect()
308}
309
310/// The capitalized identifiers a C++ source mentions, used to decide which of a
311/// declaration's `#include` lines are relevant to it.
312///
313/// Deliberately lexical, and the only place in this crate that is: the input is
314/// a rendered source excerpt whose enclosing translation unit is not available
315/// to parse, and the output feeds a *filter* over already-resolved includes, so
316/// an over-broad token set costs recall on the filter rather than inventing a
317/// declaration. Every fleet language has this same shape
318/// (`brokk_bifrost_python::graph_support::extract_type_identifiers` is the
319/// closest sibling).
320pub fn extract_type_identifiers(source: &str) -> BTreeSet<String> {
321    static IDENT_RE: OnceLock<Regex> = OnceLock::new();
322    let regex =
323        IDENT_RE.get_or_init(|| Regex::new(r"[A-Za-z_][A-Za-z0-9_:<>]*").expect("valid regex"));
324    regex
325        .find_iter(source)
326        .map(|m| m.as_str())
327        .filter(|token| {
328            token
329                .chars()
330                .next()
331                .is_some_and(|ch| ch.is_ascii_uppercase())
332        })
333        .map(|token| token.trim_matches(':').to_string())
334        .collect()
335}
336
337/// Whether the structural receiver queries apply to `file`.
338///
339/// `Language::Cpp` also covers plain `.c`, which has no member-call receivers
340/// for those queries to resolve, so the route is gated on the extension.
341pub fn receiver_query_supported(file: &ProjectFile) -> bool {
342    file.rel_path()
343        .extension()
344        .and_then(|extension| extension.to_str())
345        != Some("c")
346}
347
348fn lexical_project_relative_include_path(
349    project_root: &Path,
350    include_path: &Path,
351) -> Option<PathBuf> {
352    let root = slash_path(project_root);
353    let include = slash_path(include_path);
354    strip_slash_prefix(&include, &root).map(PathBuf::from)
355}
356
357fn slash_path(path: &Path) -> String {
358    let raw = path.to_string_lossy();
359    let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw);
360    raw.replace('\\', "/").trim_end_matches('/').to_string()
361}
362
363#[cfg(windows)]
364fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
365    if path.eq_ignore_ascii_case(root) {
366        return Some("");
367    }
368    if path.len() > root.len()
369        && path.as_bytes().get(root.len()) == Some(&b'/')
370        && path[..root.len()].eq_ignore_ascii_case(root)
371    {
372        return Some(&path[root.len() + 1..]);
373    }
374    None
375}
376
377#[cfg(not(windows))]
378fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
379    if path == root {
380        return Some("");
381    }
382    path.strip_prefix(root)
383        .and_then(|rest| rest.strip_prefix('/'))
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use std::fs;
390    use tempfile::TempDir;
391
392    fn write_file(root: &Path, rel: &str) -> ProjectFile {
393        let path = root.join(rel);
394        fs::create_dir_all(path.parent().expect("test file has parent")).unwrap();
395        fs::write(&path, "").unwrap();
396        ProjectFile::new(root.to_path_buf(), rel)
397    }
398
399    #[test]
400    fn indexed_include_resolution_uses_unique_suffix_fallback() {
401        let temp = TempDir::new().unwrap();
402        let root = temp.path().canonicalize().unwrap();
403        let source = write_file(&root, "src/lib.c");
404        let target = write_file(&root, "include/git2/sys/credential.h");
405        let duplicate = write_file(&root, "vendor/credential.h");
406        let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
407
408        let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
409        assert_eq!(resolved, vec![target]);
410
411        let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
412        assert!(ambiguous.is_empty());
413    }
414
415    #[test]
416    fn indexed_include_resolution_prefers_unique_source_reachable_root() {
417        let temp = TempDir::new().unwrap();
418        let root = temp.path().canonicalize().unwrap();
419        let source = write_file(&root, "src/config/parse.c");
420        let target = write_file(&root, "src/config/parse.h");
421        let nested_decoy = write_file(&root, "src/build/config/parse.h");
422        let unrelated_source = write_file(&root, "app/main.c");
423        let index = IncludeTargetIndex::build([&source, &target, &nested_decoy, &unrelated_source]);
424
425        let resolved = resolve_include_targets_with_index(&source, "config/parse.h", &index);
426        assert_eq!(resolved, vec![target.clone()]);
427
428        let unrelated =
429            resolve_include_targets_with_index(&unrelated_source, "config/parse.h", &index);
430        assert!(unrelated.is_empty());
431
432        let ambiguous_source = write_file(&root, "src/config/deeper/main.c");
433        let second_reachable = write_file(&root, "src/config/config/parse.h");
434        let ambiguous_index = IncludeTargetIndex::build([
435            &ambiguous_source,
436            &target,
437            &nested_decoy,
438            &second_reachable,
439        ]);
440        let ambiguous = resolve_include_targets_with_index(
441            &ambiguous_source,
442            "config/parse.h",
443            &ambiguous_index,
444        );
445        assert!(ambiguous.is_empty());
446    }
447}