brokk_bifrost_cpp/
imports.rs1use 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
17pub 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
232pub 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
295pub 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
322pub 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}