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::project::Project;
10use brokk_bifrost_core::hash::{HashMap, HashSet};
11use regex::Regex;
12use std::collections::BTreeSet;
13use std::path::{Path, PathBuf};
14use std::sync::OnceLock;
15
16/// Workspace-wide resolution table for `#include` targets: every analyzable file
17/// keyed both by its full workspace-relative path and by its bare file name.
18///
19/// Built once per analyzer generation from `all_files()` and consulted by every
20/// include-visibility walk, so a header's dependents resolve without a
21/// filesystem probe per include line.
22pub struct IncludeTargetIndex {
23    by_rel_path: HashMap<PathBuf, Vec<ProjectFile>>,
24    by_file_name: HashMap<String, Vec<ProjectFile>>,
25}
26
27impl IncludeTargetIndex {
28    pub fn build<'a>(files: impl IntoIterator<Item = &'a ProjectFile>) -> Self {
29        let mut by_rel_path: HashMap<PathBuf, Vec<ProjectFile>> = HashMap::default();
30        let mut by_file_name: HashMap<String, Vec<ProjectFile>> = HashMap::default();
31        for file in files {
32            by_rel_path
33                .entry(file.rel_path().to_path_buf())
34                .or_default()
35                .push(file.clone());
36            if let Some(file_name) = file.rel_path().file_name().and_then(|value| value.to_str()) {
37                by_file_name
38                    .entry(file_name.to_string())
39                    .or_default()
40                    .push(file.clone());
41            }
42        }
43        Self {
44            by_rel_path,
45            by_file_name,
46        }
47    }
48
49    pub fn resolve_indexed(&self, include: &str) -> Vec<ProjectFile> {
50        let include_path = Path::new(include);
51        let mut matched = HashSet::default();
52        let mut resolved = Vec::new();
53        if let Some(targets) = self.by_rel_path.get(include_path) {
54            for target in targets {
55                if matched.insert(target.clone()) {
56                    resolved.push(target.clone());
57                }
58            }
59        }
60        for suffix in string_suffixes(include) {
61            if let Some(targets) = self.by_file_name.get(suffix) {
62                for target in targets {
63                    if matched.insert(target.clone()) {
64                        resolved.push(target.clone());
65                    }
66                }
67            }
68        }
69        resolved
70    }
71
72    fn resolve_direct(&self, source_file: &ProjectFile, include: &str) -> Vec<ProjectFile> {
73        let include_path = Path::new(include);
74        let mut matched = HashSet::default();
75        let mut resolved = Vec::new();
76        if include_path.is_absolute() {
77            if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
78            {
79                self.extend_rel_path(&rel_path, &mut matched, &mut resolved);
80            }
81            return resolved;
82        }
83
84        let source_relative = ProjectFile::new(
85            source_file.root().to_path_buf(),
86            source_file.parent().join(include_path),
87        );
88        self.extend_rel_path(source_relative.rel_path(), &mut matched, &mut resolved);
89
90        let project_relative =
91            ProjectFile::new(source_file.root().to_path_buf(), include_path.to_path_buf());
92        self.extend_rel_path(project_relative.rel_path(), &mut matched, &mut resolved);
93        resolved
94    }
95
96    fn extend_rel_path(
97        &self,
98        rel_path: &Path,
99        matched: &mut HashSet<ProjectFile>,
100        out: &mut Vec<ProjectFile>,
101    ) {
102        if let Some(targets) = self.by_rel_path.get(rel_path) {
103            for target in targets {
104                if matched.insert(target.clone()) {
105                    out.push(target.clone());
106                }
107            }
108        }
109    }
110
111    fn resolve_unique_fallback(&self, include: &str) -> Vec<ProjectFile> {
112        let include_path = Path::new(include);
113        let matches: Vec<_> = self
114            .resolve_indexed(include)
115            .into_iter()
116            .filter(|file| {
117                if include_path.components().count() > 1 {
118                    file.rel_path().ends_with(include_path)
119                } else {
120                    file.rel_path()
121                        .file_name()
122                        .is_some_and(|name| name == include_path)
123                }
124            })
125            .collect();
126        if matches.len() == 1 {
127            matches
128        } else {
129            Vec::new()
130        }
131    }
132}
133
134fn string_suffixes(value: &str) -> impl Iterator<Item = &str> {
135    value.char_indices().map(|(index, _)| &value[index..])
136}
137
138pub fn parse_quoted_include(line: &str) -> Option<String> {
139    let trimmed = line.trim();
140    let quote_start = trimmed.find('"')?;
141    let quote_end = trimmed[quote_start + 1..].find('"')?;
142    Some(trimmed[quote_start + 1..quote_start + 1 + quote_end].to_string())
143}
144
145pub fn parse_include_path(line: &str) -> Option<String> {
146    if let Some(path) = parse_quoted_include(line) {
147        return Some(path);
148    }
149    let trimmed = line.trim();
150    let angle_start = trimmed.find('<')?;
151    let angle_end = trimmed[angle_start + 1..].find('>')?;
152    Some(trimmed[angle_start + 1..angle_start + 1 + angle_end].to_string())
153}
154
155pub fn resolve_include_targets(
156    project: &dyn Project,
157    source_file: &ProjectFile,
158    include: &str,
159) -> Vec<ProjectFile> {
160    let mut candidates = Vec::new();
161    let include_path = Path::new(include);
162    let source_root = project.root().to_path_buf();
163    let relative_path = if include_path.is_absolute() {
164        match project_relative_include_path(project.root(), include_path) {
165            Some(path) => path,
166            None => return candidates,
167        }
168    } else {
169        source_file.parent().join(include_path)
170    };
171    let relative_file = ProjectFile::new(source_root.clone(), relative_path);
172    if relative_file.exists() {
173        candidates.push(relative_file);
174    }
175    if !include_path.is_absolute() {
176        let project_relative_file = ProjectFile::new(source_root.clone(), include_path);
177        if project_relative_file.exists() {
178            candidates.push(project_relative_file);
179        }
180    }
181
182    candidates.sort();
183    candidates.dedup();
184    candidates
185}
186
187pub fn resolve_include_targets_with_index(
188    source_file: &ProjectFile,
189    include: &str,
190    include_targets: &IncludeTargetIndex,
191) -> Vec<ProjectFile> {
192    let mut candidates = include_targets.resolve_direct(source_file, include);
193    if !candidates.is_empty() {
194        return candidates;
195    }
196    if Path::new(include).is_absolute() {
197        return candidates;
198    }
199    candidates.extend(include_targets.resolve_unique_fallback(include));
200    candidates
201}
202
203pub fn resolve_direct_include_targets_with_index(
204    source_file: &ProjectFile,
205    include: &str,
206    include_targets: &IncludeTargetIndex,
207) -> Vec<ProjectFile> {
208    include_targets.resolve_direct(source_file, include)
209}
210
211fn project_relative_include_path(project_root: &Path, include_path: &Path) -> Option<PathBuf> {
212    let canonical_root = project_root
213        .canonicalize()
214        .unwrap_or_else(|_| project_root.to_path_buf());
215    let canonical_include = include_path
216        .canonicalize()
217        .unwrap_or_else(|_| include_path.to_path_buf());
218    canonical_include
219        .strip_prefix(&canonical_root)
220        .map(Path::to_path_buf)
221        .or_else(|_| {
222            include_path
223                .strip_prefix(project_root)
224                .map(Path::to_path_buf)
225        })
226        .ok()
227        .or_else(|| lexical_project_relative_include_path(&canonical_root, &canonical_include))
228        .or_else(|| lexical_project_relative_include_path(project_root, include_path))
229}
230
231pub fn quoted_include_paths(parsed: &[String]) -> Vec<String> {
232    parsed
233        .iter()
234        .filter_map(|line| parse_quoted_include(line))
235        .collect()
236}
237
238pub fn include_paths(parsed: &[String]) -> Vec<String> {
239    parsed
240        .iter()
241        .filter_map(|line| parse_include_path(line))
242        .collect()
243}
244
245/// The capitalized identifiers a C++ source mentions, used to decide which of a
246/// declaration's `#include` lines are relevant to it.
247///
248/// Deliberately lexical, and the only place in this crate that is: the input is
249/// a rendered source excerpt whose enclosing translation unit is not available
250/// to parse, and the output feeds a *filter* over already-resolved includes, so
251/// an over-broad token set costs recall on the filter rather than inventing a
252/// declaration. Every fleet language has this same shape
253/// (`brokk_bifrost_python::graph_support::extract_type_identifiers` is the
254/// closest sibling).
255pub fn extract_type_identifiers(source: &str) -> BTreeSet<String> {
256    static IDENT_RE: OnceLock<Regex> = OnceLock::new();
257    let regex =
258        IDENT_RE.get_or_init(|| Regex::new(r"[A-Za-z_][A-Za-z0-9_:<>]*").expect("valid regex"));
259    regex
260        .find_iter(source)
261        .map(|m| m.as_str())
262        .filter(|token| {
263            token
264                .chars()
265                .next()
266                .is_some_and(|ch| ch.is_ascii_uppercase())
267        })
268        .map(|token| token.trim_matches(':').to_string())
269        .collect()
270}
271
272/// Whether the structural receiver queries apply to `file`.
273///
274/// `Language::Cpp` also covers plain `.c`, which has no member-call receivers
275/// for those queries to resolve, so the route is gated on the extension.
276pub fn receiver_query_supported(file: &ProjectFile) -> bool {
277    file.rel_path()
278        .extension()
279        .and_then(|extension| extension.to_str())
280        != Some("c")
281}
282
283fn lexical_project_relative_include_path(
284    project_root: &Path,
285    include_path: &Path,
286) -> Option<PathBuf> {
287    let root = slash_path(project_root);
288    let include = slash_path(include_path);
289    strip_slash_prefix(&include, &root).map(PathBuf::from)
290}
291
292fn slash_path(path: &Path) -> String {
293    let raw = path.to_string_lossy();
294    let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw);
295    raw.replace('\\', "/").trim_end_matches('/').to_string()
296}
297
298#[cfg(windows)]
299fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
300    if path.eq_ignore_ascii_case(root) {
301        return Some("");
302    }
303    if path.len() > root.len()
304        && path.as_bytes().get(root.len()) == Some(&b'/')
305        && path[..root.len()].eq_ignore_ascii_case(root)
306    {
307        return Some(&path[root.len() + 1..]);
308    }
309    None
310}
311
312#[cfg(not(windows))]
313fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
314    if path == root {
315        return Some("");
316    }
317    path.strip_prefix(root)
318        .and_then(|rest| rest.strip_prefix('/'))
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use std::fs;
325    use tempfile::TempDir;
326
327    fn write_file(root: &Path, rel: &str) -> ProjectFile {
328        let path = root.join(rel);
329        fs::create_dir_all(path.parent().expect("test file has parent")).unwrap();
330        fs::write(&path, "").unwrap();
331        ProjectFile::new(root.to_path_buf(), rel)
332    }
333
334    #[test]
335    fn indexed_include_resolution_uses_unique_suffix_fallback() {
336        let temp = TempDir::new().unwrap();
337        let root = temp.path().canonicalize().unwrap();
338        let source = write_file(&root, "src/lib.c");
339        let target = write_file(&root, "include/git2/sys/credential.h");
340        let duplicate = write_file(&root, "vendor/credential.h");
341        let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
342
343        let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
344        assert_eq!(resolved, vec![target]);
345
346        let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
347        assert!(ambiguous.is_empty());
348    }
349}