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(&self, include: &str) -> Vec<ProjectFile> {
113        let include_path = Path::new(include);
114        let matches: Vec<_> = self
115            .resolve_indexed(include)
116            .into_iter()
117            .filter(|file| {
118                if include_path.components().count() > 1 {
119                    file.rel_path().ends_with(include_path)
120                } else {
121                    file.rel_path()
122                        .file_name()
123                        .is_some_and(|name| name == include_path)
124                }
125            })
126            .collect();
127        if matches.len() == 1 {
128            matches
129        } else {
130            Vec::new()
131        }
132    }
133}
134
135fn string_suffixes(value: &str) -> impl Iterator<Item = &str> {
136    value.char_indices().map(|(index, _)| &value[index..])
137}
138
139pub fn parse_quoted_include(line: &str) -> Option<String> {
140    let trimmed = line.trim();
141    let quote_start = trimmed.find('"')?;
142    let quote_end = trimmed[quote_start + 1..].find('"')?;
143    Some(trimmed[quote_start + 1..quote_start + 1 + quote_end].to_string())
144}
145
146pub fn parse_include_path(line: &str) -> Option<String> {
147    if let Some(path) = parse_quoted_include(line) {
148        return Some(path);
149    }
150    let trimmed = line.trim();
151    let angle_start = trimmed.find('<')?;
152    let angle_end = trimmed[angle_start + 1..].find('>')?;
153    Some(trimmed[angle_start + 1..angle_start + 1 + angle_end].to_string())
154}
155
156pub fn resolve_include_targets(
157    project: &dyn Project,
158    source_file: &ProjectFile,
159    include: &str,
160) -> Vec<ProjectFile> {
161    let mut candidates = Vec::new();
162    let include_path = Path::new(include);
163    let source_root = project.root().to_path_buf();
164    let relative_path = if include_path.is_absolute() {
165        match project_relative_include_path(project.root(), include_path) {
166            Some(path) => path,
167            None => return candidates,
168        }
169    } else {
170        source_file.parent().join(include_path)
171    };
172    let relative_file = ProjectFile::new(source_root.clone(), relative_path);
173    if relative_file.exists() {
174        candidates.push(relative_file);
175    }
176    if !include_path.is_absolute() {
177        let project_relative_file = ProjectFile::new(source_root.clone(), include_path);
178        if project_relative_file.exists() {
179            candidates.push(project_relative_file);
180        }
181    }
182
183    candidates.sort();
184    candidates.dedup();
185    candidates
186}
187
188pub fn resolve_include_targets_with_index(
189    source_file: &ProjectFile,
190    include: &str,
191    include_targets: &IncludeTargetIndex,
192) -> Vec<ProjectFile> {
193    let mut candidates = include_targets.resolve_direct(source_file, include);
194    if !candidates.is_empty() {
195        return candidates;
196    }
197    if Path::new(include).is_absolute() {
198        return candidates;
199    }
200    candidates.extend(include_targets.resolve_unique_fallback(include));
201    candidates
202}
203
204pub fn resolve_direct_include_targets_with_index(
205    source_file: &ProjectFile,
206    include: &str,
207    include_targets: &IncludeTargetIndex,
208) -> Vec<ProjectFile> {
209    include_targets.resolve_direct(source_file, include)
210}
211
212fn project_relative_include_path(project_root: &Path, include_path: &Path) -> Option<PathBuf> {
213    let canonical_root = project_root
214        .canonicalize()
215        .unwrap_or_else(|_| project_root.to_path_buf());
216    let canonical_include = include_path
217        .canonicalize()
218        .unwrap_or_else(|_| include_path.to_path_buf());
219    canonical_include
220        .strip_prefix(&canonical_root)
221        .map(Path::to_path_buf)
222        .or_else(|_| {
223            include_path
224                .strip_prefix(project_root)
225                .map(Path::to_path_buf)
226        })
227        .ok()
228        .or_else(|| lexical_project_relative_include_path(&canonical_root, &canonical_include))
229        .or_else(|| lexical_project_relative_include_path(project_root, include_path))
230}
231
232/// The claim edges `sources` contribute: for each source file, the workspace
233/// files it pulls in by quoted `#include` that no language's extension registry
234/// claims (#1837).
235///
236/// `sources` pairs each already-analyzed C++ file with the `ImportInfo` rows
237/// recorded for it; `claimable` is the caller's set of workspace files with an
238/// extension no language owns. `abseil`'s `.inc` translation-unit fragments are
239/// the motivating case: nothing indexes them today, so every declaration they
240/// hold is invisible in both directions.
241///
242/// Only quoted includes participate. An angled include names a search path the
243/// analyzer does not model, so resolving one against workspace file names would
244/// claim files the compiler would never reach.
245///
246/// Edges rather than a flat set, because the caller both closes the relation
247/// transitively and drops a claim when the last `#include` naming it goes away;
248/// both need to know which source contributed which target. A source with no
249/// claimable include contributes no entry.
250///
251/// The result depends only on `sources`, `claimable` and the resolution rules
252/// in this module -- never on the order either collection arrives in.
253pub fn included_claimable_files(
254    sources: &[(ProjectFile, Vec<ImportInfo>)],
255    claimable: &BTreeSet<ProjectFile>,
256) -> HashMap<ProjectFile, BTreeSet<ProjectFile>> {
257    let mut edges: HashMap<ProjectFile, BTreeSet<ProjectFile>> = HashMap::default();
258    if claimable.is_empty() || sources.is_empty() {
259        return edges;
260    }
261    let index = IncludeTargetIndex::build(claimable.iter());
262    for (source_file, imports) in sources {
263        let mut targets = BTreeSet::new();
264        for include in imports
265            .iter()
266            .filter_map(|import| parse_quoted_include(&import.raw_snippet))
267        {
268            targets.extend(resolve_include_targets_with_index(
269                source_file,
270                &include,
271                &index,
272            ));
273        }
274        if !targets.is_empty() {
275            edges.insert(source_file.clone(), targets);
276        }
277    }
278    edges
279}
280
281pub fn quoted_include_paths(parsed: &[String]) -> Vec<String> {
282    parsed
283        .iter()
284        .filter_map(|line| parse_quoted_include(line))
285        .collect()
286}
287
288pub fn include_paths(parsed: &[String]) -> Vec<String> {
289    parsed
290        .iter()
291        .filter_map(|line| parse_include_path(line))
292        .collect()
293}
294
295/// The capitalized identifiers a C++ source mentions, used to decide which of a
296/// declaration's `#include` lines are relevant to it.
297///
298/// Deliberately lexical, and the only place in this crate that is: the input is
299/// a rendered source excerpt whose enclosing translation unit is not available
300/// to parse, and the output feeds a *filter* over already-resolved includes, so
301/// an over-broad token set costs recall on the filter rather than inventing a
302/// declaration. Every fleet language has this same shape
303/// (`brokk_bifrost_python::graph_support::extract_type_identifiers` is the
304/// closest sibling).
305pub fn extract_type_identifiers(source: &str) -> BTreeSet<String> {
306    static IDENT_RE: OnceLock<Regex> = OnceLock::new();
307    let regex =
308        IDENT_RE.get_or_init(|| Regex::new(r"[A-Za-z_][A-Za-z0-9_:<>]*").expect("valid regex"));
309    regex
310        .find_iter(source)
311        .map(|m| m.as_str())
312        .filter(|token| {
313            token
314                .chars()
315                .next()
316                .is_some_and(|ch| ch.is_ascii_uppercase())
317        })
318        .map(|token| token.trim_matches(':').to_string())
319        .collect()
320}
321
322/// Whether the structural receiver queries apply to `file`.
323///
324/// `Language::Cpp` also covers plain `.c`, which has no member-call receivers
325/// for those queries to resolve, so the route is gated on the extension.
326pub fn receiver_query_supported(file: &ProjectFile) -> bool {
327    file.rel_path()
328        .extension()
329        .and_then(|extension| extension.to_str())
330        != Some("c")
331}
332
333fn lexical_project_relative_include_path(
334    project_root: &Path,
335    include_path: &Path,
336) -> Option<PathBuf> {
337    let root = slash_path(project_root);
338    let include = slash_path(include_path);
339    strip_slash_prefix(&include, &root).map(PathBuf::from)
340}
341
342fn slash_path(path: &Path) -> String {
343    let raw = path.to_string_lossy();
344    let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw);
345    raw.replace('\\', "/").trim_end_matches('/').to_string()
346}
347
348#[cfg(windows)]
349fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
350    if path.eq_ignore_ascii_case(root) {
351        return Some("");
352    }
353    if path.len() > root.len()
354        && path.as_bytes().get(root.len()) == Some(&b'/')
355        && path[..root.len()].eq_ignore_ascii_case(root)
356    {
357        return Some(&path[root.len() + 1..]);
358    }
359    None
360}
361
362#[cfg(not(windows))]
363fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
364    if path == root {
365        return Some("");
366    }
367    path.strip_prefix(root)
368        .and_then(|rest| rest.strip_prefix('/'))
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use std::fs;
375    use tempfile::TempDir;
376
377    fn write_file(root: &Path, rel: &str) -> ProjectFile {
378        let path = root.join(rel);
379        fs::create_dir_all(path.parent().expect("test file has parent")).unwrap();
380        fs::write(&path, "").unwrap();
381        ProjectFile::new(root.to_path_buf(), rel)
382    }
383
384    #[test]
385    fn indexed_include_resolution_uses_unique_suffix_fallback() {
386        let temp = TempDir::new().unwrap();
387        let root = temp.path().canonicalize().unwrap();
388        let source = write_file(&root, "src/lib.c");
389        let target = write_file(&root, "include/git2/sys/credential.h");
390        let duplicate = write_file(&root, "vendor/credential.h");
391        let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
392
393        let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
394        assert_eq!(resolved, vec![target]);
395
396        let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
397        assert!(ambiguous.is_empty());
398    }
399}